Skip to content

Commit c8b062f

Browse files
feat(metadata-protocol,objectql,rest,spec)!: every metadata read is environment → code; legacy organization rows and sealed overlays are reported at boot, not served (#15206 S5) (#22628)
Refs #15206 (S5) Clause-②: no (narrowing) — every metadata read is environment → code; no read refused today is admitted. **Patch round 1** (2026-10-10T11:39Z), answering contract review 6095742860 and the docs-drift note 6094691685: main merged at `d5dac9ba0` (`protocol.ts` merged clean; #22591's create-remedy change kept whole); `spec-changes.json` regenerated in `6c7c6c28d` (it then carried `metadata-read-organization-scope-retired`); the legacy-row boot report counts the three ledgers and projects `sys_metadata` to the four printed columns, pinned with its ablation (`82e2de41b`, the cast dropped in `b45b42a6c`); the `environment-variables.mdx` merge-at-read sentence made true (`62f64d223`). main merged again at `40e6b5d6d` because #22638 had landed and left the PR conflicted: the modify/delete on `packages/spec/spec-changes.json` keeps main's deletion (the file is now generated at publish only). **Stage S5 of #15206 only.** #15206 remains open: S6 and the column / index / history re-keys (#15211, triage ruling Q2 B) are not in this PR. ## What changes ADR-0131 D6 retired the per-organization overlay axis. S3/S4 stopped the doors carrying an organization and made the protocol refuse every organization-scoped write. This PR makes every protocol **read** environment → code too: - **Reads are env-only.** The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads now serve the environment's stored row, else the code packages' definition, whatever organization the caller names. So do the `_lock` gate's overlay layer, search's page sweep, diagnostics, references and boot hydration. A row stored organization-scoped by an earlier release is served by no read. - The rule lives in one place: `servedOverlayRowCandidates` / `findServedOverlayRow` and the overlay cache key `[type, packageId]`. - `ItemAddress` loses `organizationId`, and the ETag validator is locale plus content. - **Sealed managed overlays are not served (triage ruling Q1 → C).** This covers an environment row overlaying an item a managed package ships, on a type sealed against overlays: any type whose registry entry admits no overlay (a flow, action, hook, object or datasource, for example). Such a row was written through `OS_METADATA_WRITABLE` before the seal. - `declinesStoredRow` gains one arm, `isUnservedSealedOverlay`. The package's definition wins. - An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. - **Permission sets are carved out.** A stored fork of a code-declared permission set keeps its 2026-08-24 ruling: detection reading plus Discard Overlay, "not a silent migration". - **Boot reports both populations, once each, per type.** Nothing is deleted or rewritten (#22011 A). - `[metadata_org_scoped_unserved]` covers the legacy organization rows (active and draft), plus the legacy rows of `sys_metadata_commit` / `_history` / `_audit`. It names `os migrate` (ADR-0131 D10). - `[metadata_sealed_overlay_unserved]` covers the sealed overlays and names the two remedies: re-express as a linkage-free clone, or delete the row. - **Anonymous form doors are unchanged (triage ruling Q3 A).** `resolveFormBySlug` and `findPublicFormView` keep reading the Default Organization's legacy `view` layer, fail-closed. - The rest door now sends the protocol-internal `legacyFormOrganizationId` instead of `organizationId`. - The protocol honours that key for `view` alone. No spec request declares it. - The behaviour is pinned unchanged in `rest/src/public-form-withdrawal.test.ts` and in dogfood (below). - **`@objectstack/objectql`** binds the environment's authored hook and action rows only. Before, `readAuthoredHookRows` / `readAuthoredActionRows` bound every organization's rows. - **Spec (narrowing).** - `organizationId` leaves `GetMetaItems`, `GetMetaItem`, `GetMetaItemLayered`, `AuditMetaItem`, `HistoryMetaItem` and `GetMetaItemCached`, and the `ListDraftsResponse` drafts. - `overlayScope` is `'env' | null`. - New ADR-0087 semantic entry `metadata-read-organization-scope-retired`. - The `authorable-surface/api.json` lines for the six vanished keys were removed by hand, following S4's route: the build and `gen:authorable-surface-base` both refuse a vanished key. ### Carried findings | Finding | Outcome | |:--|:--| | Dead `organizationId` spread into the authoring gate | removed: `MetadataAuthoringGateContext.organizationId` is gone; no gate read it | | Seed loader's refusal prescribed `config.organizationId` | now prescribes `organization_id` on the record first (a package-published seed has no caller organization) | | `listCommits`' organization branch | **report, don't serve**: it lists `organization_id IS NULL` commits only, for every caller; legacy commits are counted in the boot report | | `metadata-lifecycle.mdx` "Per-org overlay" wording | rewritten; the D6 callout is extended; a sealed-overlay paragraph is added; `metadata-service.mdx` is aligned | | objectql reads every organization's hook/action rows | environment rows only (a row-value check, `isLegacyOrganizationRow`: a where-clause spelling trips the federated-readers gate) | | `declaresOrgOverride` / `organizationIdForMetaRead` callers | **none left; deleted** (`metadata-core/src/meta-write-org-scope.ts` and its test) | | Read callers outside the claim passing `organizationId` | plugin-security `permission-set-projection.ts` / `permission-set-overlay-discard.ts` (dropped); the rest form doors (now the legacy key); the rest audit door (`organizationId: null` dropped); runtime `domains/packages.ts` (comments only) | ### Cross-lane paths - **domain:cli**: `packages/rest/src/execctx-consumer-census.test.ts`, `packages/rest/src/meta-item-layered-route.test.ts`, `packages/rest/src/meta-object-extension-property-classes.test.ts`, `packages/rest/src/meta-object-overlay-extension-fold.test.ts`, `packages/rest/src/public-form-withdrawal.test.ts`, `packages/rest/src/rest-server-audit-org-scope.test.ts`, `packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts`, `packages/rest/src/rest-server.ts`, `packages/runtime/src/audit-meta-item-org-scope.integration.test.ts`, `packages/runtime/src/domains/domain-protocol-handle-typing.test.ts`, `packages/runtime/src/domains/meta-read-org-scope-parity.test.ts`, `packages/runtime/src/domains/packages-protocol-handle-typing.test.ts`, `packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts`, `packages/runtime/src/domains/packages.ts`, `packages/runtime/src/meta-write-org-scope.test.ts`, `packages/runtime/src/package-list-commits-org-scope.integration.test.ts`, `packages/runtime/src/package-revert-stored-members.integration.test.ts` - **domain:devx**: `content/docs/concepts/metadata-lifecycle.mdx`, `content/docs/kernel/contracts/metadata-service.mdx`, `content/docs/references/api/protocol.mdx`, `packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts`, `scripts/engine-double-contract.pinned.json`, `scripts/objectql-double-limit.baseline.json` - **domain:services**: `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts`, `packages/plugins/plugin-security/src/permission-set-projection.test.ts`, `packages/plugins/plugin-security/src/permission-set-projection.ts` - **domain:spec**: `packages/spec/authorable-surface/api.json`, `packages/spec/src/api/meta-item-response-shapes.test.ts`, `packages/spec/src/api/protocol.test.ts`, `packages/spec/src/api/protocol.zod.ts`, `packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts`, `packages/spec/src/migrations/registry.ts` ## Census (Q1 → C: census first) Measured on the two seeded example apps, booted fresh with their seeds by a throwaway `@objectstack/verify` probe at HEAD. The probe was not committed. It counted every row of the four ledger tables through the engine: | App | `sys_metadata` rows | legacy organization rows | env rows carrying a package | `_commit` / `_history` / `_audit` legacy rows | |:--|--:|--:|--:|--:| | CRM (`@objectstack/example-crm`) | 0 | 0 | 0 | 0 / 0 / 0 | | Showcase (`examples/app-showcase`) | 0 | 0 | 0 | 0 / 0 / 0 | **What this shows.** A freshly seeded deployment carries neither population. Nothing is dropped, and nothing in a fresh install changes what it serves. **What it does not show.** Real deployment databases are not reachable from this container, so their counts are **NOT MEASURED**. The boot report this PR adds is the census instrument for them. It prints both populations per type on the first boot of this build, before anyone acts on a row. No row is touched, which honours "nothing is dropped by guess" and #22011 A. **Both reports fired correctly in the dogfood run**, on fixtures that plant the rows: - `[metadata_org_scoped_unserved] … flow×1 (showcase_task_assigned_notify@org_dogfood_21002)` - `[metadata_sealed_overlay_unserved] … object×1 (showcase_account)`, `flow×1 (showcase_urgent_task_alert)`, `datasource×2 (showcase_external, default)` ## Reverse verification Both ablations ran from committed state `2ccf45701` through `scripts/ablation-replace.mjs` (WRAP mode, anchor hit ×1 and verified on disk). The subject is `metadata-protocol/src`, imported from source by the pin, so no `dist` leg applies. | # | Mutation (`protocol.ts`) | Expected | Observed | |:--|:--|:--|:--| | 1 | drop `organization_id: candidate.organizationId` from `findServedOverlayRow`'s where (blob `5c3dc429` → `647a3a8a`) | §1 red | **10 failed / 10 passed**: every §1 case (5 tier-A types × 2 caller shapes) | | 2 | drop `\|\| this.isUnservedSealedOverlay(type, name)` from `declinesStoredRow` (blob `5c3dc429` → `47977733`) | §2 red | **2 failed / 18 passed**: the managed action and managed hook cases | | restore | `git checkout HEAD` by absolute path | green | blob == HEAD `5c3dc429`, `git diff HEAD` empty, `ABLATION-` marker count 0; pin **20 passed** | The direction was the usual one: red under each mutation, green after restore. ## Tests The package suites ran on `4560ed20`, which carries the final source. The later commits touched no runtime source: the new pin's fake engine (`2ccf4570`; pin rerun **20 passed**), one dogfood case (`ea26adb03`; rerun **7/7 passed**), and the ADR-0087 entry / changeset / docs wording of the seal's reach (`5616042ea`). For that commit, `gen:migration-registry` was rerun, spec `check:generated --fix` reported all 15 artifacts up to date, and spec's `src/migrations` + `scripts` tests passed **1,733**. Each heavy run went through `scripts/pm/os-verify-lock.sh`. | Package | Command | Result | |:--|:--|:--| | `@objectstack/metadata-core` | `pnpm --filter … test` | 16 files, **384 passed** | | `@objectstack/metadata-protocol` | `pnpm --filter … test` | 219 files (3 skipped), **28,029 passed** / 19 skipped | | `@objectstack/objectql` | `pnpm --filter … test` | 392 files, **7,729 passed** | | `@objectstack/rest` | `pnpm --filter … test` | 268 files, **4,973 passed** / 326 skipped | | `@objectstack/runtime` | vitest `--shard=1/2`, `2/2` | **2,979 + 2,680 passed** | | `@objectstack/plugin-security` | vitest (full) | 192 files, **4,033 passed** (see note 1) | | `@objectstack/spec` | vitest `--shard=1/3`, `2/3`, `3/3` | **7,146 + 6,092 + 6,630 passed** | | new pin `protocol.reads-environment-only.test.ts` | vitest | **20 passed** | | typecheck | the seven packages above, plus `@objectstack/dogfood` | all exit 0 | | dogfood (17 files that read metadata layers) | vitest | **100 passed** after one case was re-premised (see note 2) | **Note 1 — plugin-security first run.** It failed 9 files, each with "Cannot find package '@objectstack/spec/…'", while a concurrent gate rebuilt spec's `dist`. No test body ran. The rerun on the same tree was green. **Note 2 — dogfood.** The 17 files are: - `flow-shipped-name-*` (×4), `flow-unshipped-name-layered-code` - `managed-content-sealed`, `email-template-overlay-survives-boot`, `datasource-*` (×2) - `permission-set-discard-overlay-eligibility` - `showcase-public-form-withdrawal`, `showcase-public-form-withdrawal-layers`, `public-form-withdrawal-walled` - `showcase-object-extension-scalar-divergence`, `external-validate-sees-runtime-save`, `package-first-authoring`, `object-designer-field-reorder` The first run had **1 failed / 99 passed**: `showcase-object-extension-scalar-divergence` › "SHOULD: a tenant's own rename reaches the reads its forms derive from". That case pinned a pre-seal environment rename of the **packaged** object `showcase_account` being served, which is exactly what triage ruling Q1 → C stops. It is re-premised, not weakened: the row is still at rest and shown by `?layers=true`, the item and list reads serve the package's label, and boot names the row. The file then ran **7/7 passed**. **Q3 A holds.** The three public-form withdrawal dogfood files are green. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **134** families at `5616042ea`, the same set as one commit earlier. All 134 ran on that tree, each exit code captured before any pipe. **Reconciliation:** `--ran` prints `134 derived, 134 run, 0 NOT-MEASURED, 0 UNRUN`. The zero is derived from the recorded codes: every family exited 0. **Repaired along the way:** - `check:engine-double-contract`: the ledger is rewritten with `--write`. Two pins left with the deleted files, and the new pin's three doubles were added. - `check:objectql-double-limit`: two baseline entries removed for files that are gone or now clean. The new pin's `find` double now applies `limit`. - `check:where-matcher`: the new pin's matcher now refuses any combinator or operator it does not implement. **Gates that refused at first:** - `check-engine-split-ratio --days 90` and `check-plugin-teardown-shape --self-test` refused on the shallow clone. After deepening they measured, and passed. - `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n` refused at first because packages had no `dist`. Once the dogfood closure was built they measured, and passed. **Notes the gates print themselves, with exit 0:** - `check-spec-docblock-symbol-anchors --self-test`: "second reality pin NOT TAKEN" (the objectui sha is not in this clone). - `check:entry-nameability`: "NOT MEASURED: no callable export on @objectstack/spec/api-assembled, @objectstack/spec/qa". It prints this regardless of this diff. **Repo-wide lint** (`pnpm lint`) was not run locally; that run belongs to CI. ## Size route: human merge The diff is 6,188 changed lines across 71 files, over `HUMAN_MERGE_LINE_THRESHOLD` = 3000 (`scripts/pm/check-governed-merges.mjs`), so this PR takes the **human route**. About 70% of the size is deletion: whole test files whose subject S5 retires (five organization-read-gate and ETag-scope pins, and the `meta-write-org-scope` helper and its test), plus the organization arms of re-premised pins. Breakdown: 1,768 added + 4,420 deleted = **6,188** at `40e6b5d6d`; source +615/−1,249, tests +1,023/−3,114, docs+changeset +66/−25, generated/ledgers +64/−32. ## objectui at the pinned `.objectui-sha` (report only; carrier C9) At `47b1f0bb71`: - **Live behaviour.** `LayeredDiff.tsx` renders `translateConsoleValue('layer', layered.overlayScope ?? 'set')`. After S5 the server never emits `'org'`, so the `org` label is dead but harmless. - **Type break on the next spec bump.** `packages/data-objectstack/src/metadata-client.ts:374` derives `MetadataOverlayScope` from `GetMetaItemLayeredResponse['overlayScope']`. `packages/app-shell/src/views/metadata-admin/i18n.ts:7151` declares `LAYER_SCOPE_ZH` as a `Record` keyed by the non-null `MetadataOverlayScope` values, with an `org` key. Once objectui moves to this spec, that literal's `org` key is an excess property (a typecheck error). - **Tests to update with that bump.** Several test fixtures spell `overlayScope: 'org'`: `metadata-client.layeredEnvelope/layeredRoute`, `ResourceEditPage.resetVerdict` and `StudioDesignSurface.listViewInspector-11823`. No objectui edit here. ## Acceptance notes - **Boot layering.** ADR-0029 D9's boot layering of a sealed `object` overlay is no longer applied. The packaged schema stands, and the row is named in the sealed report. Sealed rows of other types are still hydrated as bare tenant copies (the #20913 precedent) and declined at read. - **Legacy commit revert.** `revertCommit` of a legacy organization commit is still refused 403 by S4's gate (not 404). - **Repository option kept.** The repository's `organizationId` option stays, used only by `legacyOrganizationRepo` for the uninstall removal. - **Changeset.** It is minor (pre mode, tag `next`). `@objectstack/runtime` is not in it: its source change is comments only. ## 维护者速读(草稿) **改了什么**:元数据的所有读取口(单项、列表、分层、ETag 缓存、历史、差异、审计、草稿、提交时间线、锁、搜索、引用、启动加载)一律只读「环境 → 代码」两层,不再读任何组织级旧行。旧版本存下的组织级行、以及在封存前通过 `OS_METADATA_WRITABLE` 写下的「托管包内容的覆盖行」(flow/action/hook/object/datasource 等不允许覆盖的类型),都不再被使用,启动时按类型各报一次,并给出处理办法。公开表单的匿名入口照旧读默认组织的旧 view 行(裁决 Q3 A)。 **为什么改**:ADR-0131 D6 已退役按组织覆盖元数据的能力,写入口在 S3/S4 已全部拒绝组织级写入;如果读取口还继续提供旧的组织行,退役的那一层就会按调用方逐个"复活",不同组织看到不同的元数据。托管包内容已封存,读取也必须让包定义胜出。 **风险与代价(含回滚)**:依赖旧组织级覆盖行的部署,升级后这些定制不再生效,回到环境行或包定义(启动日志会逐条点名,数据一行未删,可在 v18 迁移仪式中提升,或在 Studio 重新保存为环境级)。依赖封存前覆盖托管内容的部署,同样回到包定义,需改为另起名的克隆。权限集的存储分叉不受影响。回滚:回退本 PR 即恢复原读取行为,无数据迁移需要撤销。objectui 下次升级 spec 时需删掉 `overlayScope` 的 `org` 键(一处类型 + 若干测试)。 **席位意见**: **你要做的**:本 PR 超过 3000 行门槛,走人工合并:请审阅后人工批准合并。重点看:权限集是否应被排除在"封存覆盖不提供"之外;封存 object 覆盖行不再在启动时叠加到引擎这一行为变化是否可接受。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0fea05f commit c8b062f

71 files changed

Lines changed: 1768 additions & 4420 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/metadata-core': minor
4+
'@objectstack/objectql': minor
5+
'@objectstack/rest': minor
6+
'@objectstack/plugin-security': minor
7+
'@objectstack/spec': minor
8+
---
9+
10+
feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6)
11+
12+
Clause-②: no (narrowing)
13+
14+
<!-- adr-0087: registered metadata-read-organization-scope-retired -->
15+
16+
**BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`.
17+
18+
ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well.
19+
20+
**What changes.**
21+
22+
- The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot.
23+
- `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone.
24+
- `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`.
25+
- `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one.
26+
- [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling (detection reading and Discard Overlay) and is still served.
27+
- `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound.
28+
- Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten.
29+
- The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone.
30+
- The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization.
31+
32+
**What moves for consumers.**
33+
34+
| From | To |
35+
|:--|:--|
36+
| `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks |
37+
| `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it |
38+
| `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's |
39+
| `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` |
40+
| `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question |
41+
| `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it |
42+
| a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it |
43+
| an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row |
44+
45+
**What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ Reads walk top-to-bottom: the first non-null layer wins. Writes always route to
4545
<Callout type="warn">
4646
Today this precedence is implemented **inside the protocol layer**, not by
4747
`LayeredRepository`. `ObjectStackProtocolImplementation` lazily builds one
48-
`SysMetadataRepository` per org (`getOverlayRepo()`) and merges it against the
49-
in-memory code registry — `getMetaItemLayered()` returns `code` / `overlay` /
48+
`SysMetadataRepository` for the environment (`getOverlayRepo()`) and merges it
49+
against the in-memory code registry — `getMetaItemLayered()` returns `code` / `overlay` /
5050
`effective` separately. `LayeredRepository` ships in `@objectstack/metadata-core`
5151
and implements exactly the semantics above, but it is **not yet composed** into
5252
any production path: every `new LayeredRepository(...)` call site is a test
@@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m
106106

107107
In shared-database multi-tenancy, **most metadata types must not be per-org customizable** — overriding them would break the physical schema. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowOrgOverride` in `packages/spec/src/kernel/metadata-plugin.zod.ts`.
108108

109-
> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is not served by any `/meta` read until the promotion ceremony (ADR-0131 C7) carries it to the environment layer; re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. The table still records which types take an overlay of a shipped item.
109+
> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read: boot names each such row, per type (`[metadata_org_scoped_unserved]`), and the v18 migration ceremony (ADR-0131 C7, `os migrate`) carries it to the environment layer; nothing deletes or rewrites it before then. Re-save the item in Studio to make the edit live now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. The table still records which types take an overlay of a shipped item.
110110

111111
| Type | `allowOrgOverride` | Rationale |
112112
| :--- | :---: | :--- |
@@ -142,6 +142,8 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
142142

143143
The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged.
144144

145+
An environment row stored over a managed item of a type sealed against overlays (a flow, action, hook, object or datasource, for example — any type whose registry entry admits no overlay) before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had.
146+
145147
---
146148

147149
## Conflict handling
@@ -176,13 +178,13 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi
176178
| :--- | :--- |
177179
| `InMemoryRepository`, `LayeredRepository` | ✅ Shipped (`@objectstack/metadata-core`) |
178180
| `FileSystemRepository` | ✅ Shipped (`@objectstack/metadata-fs`) |
179-
| Change log + `seq` (per-org, monotonic) | ✅ Shipped |
181+
| Change log + `seq` (per environment, monotonic) | ✅ Shipped |
180182
| SSE bridge (`/api/v1/dev/metadata-events`, `event: metadata-change`) | ✅ Shipped |
181183
| Granular client-side HMR hook + seq status badge | ❌ Not implemented — no such hook or badge exists in the console today |
182184
| Console dev-mode HMR reloader (`MetadataHmrReloader`) | ✅ Shipped (console app, mounted app-wide, dev-only) |
183185
| `SysMetadataRepository` (overlay over `sys_metadata`) | ✅ Shipped (`@objectstack/metadata-protocol`) |
184186
| `LayeredRepository(SysMeta + artifact)` composition | ⏳ Not wired — the class ships, but nothing composes it outside tests |
185-
| `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (via the per-org `getOverlayRepo()` cache) |
187+
| `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (via the environment's `getOverlayRepo()` repository) |
186188
| `sys_metadata_history` table (durable, org-keyed change log) | ✅ Shipped (object definition in `@objectstack/metadata-core`; written by `SysMetadataRepository`) |
187189
| Cache + Registry refactor against `MetadataRepository` | ⏳ Post-M0 |
188190

@@ -236,9 +238,9 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi
236238
### Why artifact never enters the database
237239

238240
1. **Single source of truth.** Code → artifact → memory. If artifact also wrote to `sys_metadata`, every deploy would need a reconciliation step ("the row says v3.0.0 but the code says v3.0.1") that is impossible to get right under concurrent edits.
239-
2. **Overlay stays small and auditable.** Per-org overlay = "what this organization deliberately changed at runtime". A clean table that's safe to dump, diff, or reset to factory.
241+
2. **Overlay stays small and auditable.** The overlay = "what this environment deliberately changed at runtime" (ADR-0131 D6: one environment layer, no per-organization one). A clean table that's safe to dump, diff, or reset to factory.
240242
3. **Immutable infrastructure.** Rolling back a deploy = switching image tags. No DB migration, no reverse `put`, no history replay required.
241-
4. **Per-org multi-tenant economics.** If artifacts were materialised into `sys_metadata`, every org would carry tens of thousands of baseline rows. Overlay-only keeps each org's row count proportional to its actual customisation.
243+
4. **Storage economics.** If artifacts were materialised into `sys_metadata`, every environment would carry tens of thousands of baseline rows. Overlay-only keeps its row count proportional to its actual customisation.
242244

243245
### What if I want auditable history of deployed artifact changes?
244246

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from
306306
| `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. |
307307
| `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). |
308308
| `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. |
309-
| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. Overlay rows this hatch wrote before are still served, and a row of a type that merges overlays at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed to restore the package's definition. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). |
309+
| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. An overlay row this hatch wrote before over a managed item is kept at rest but **not served**: every read takes the package's definition, and boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies — re-express the change as a new item under a new name, or delete the row. A stored fork of a packaged permission set is the one exception: it is still served, with its detection reading and Discard Overlay. Removing that permission-set row restores the package's definition. A stored row over a managed item of the other types that merge overlays at read (`position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed, but removing it changes nothing that is served: no read serves it, so the package's definition is already served. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). |
310310

311311
---
312312

0 commit comments

Comments
 (0)