Skip to content

Commit dd29bbe

Browse files
feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) (#23469)
Fixes #23429 · Part of #15211 Clause-②: yes (narrowing) ## 维护者速读 **改了什么**:本 PR 合并了 C7b 的 S2 和 S5(裁决 6180024026:两者合为一个 PR,#23461 已关闭并入此处)。 - **S2**:元数据家族的四张表(`sys_metadata`、`sys_metadata_audit`、`sys_metadata_commit`、`sys_metadata_history`)不再声明 `organization_id` 列(`systemFields.tenant: false`),索引去掉组织前缀后重建。 - 引擎读这四张表的路径统一走 `protocol.ts` 里的同一个接缝,读物理列把遗留的组织行排除在外。读不到这一列时直接拒绝,不会当作已迁移的库。 - 三张轨迹表(audit、history、commit)的数据门读取一律要求 `studio.access`(E2)。 - **S5**:`os serve` 启动时,若某张已无组织列声明的表在物理上仍带 `organization_id`,且仪式标记 `adr-0131-organization-ownership` 未验证,就拒绝启动,并点名 `os migrate organization-ownership`。 - 没有任何开关可以绕过。 - 同时 `os migrate apply` 在仪式完成前拒绝删除清单内表上的 `organization_id` 列(E1)。 **为什么改**:ADR-0131 D6/D7 规定元数据属于环境、这些表不再带组织列;D10 第 5 项要求 v18 运行时在旧库跑完仪式前拒绝启动。 **风险与代价(含回滚)**: - **破坏性变更(BREAKING)**:没跑过 v18 仪式的 17.x 库,用 `os serve` 启动会被拒绝,必须先执行 `os migrate organization-ownership --out PLAN`,再执行 `--apply --plan-file PLAN --backup-taken`。没有绕过开关。 - **收紧(Clause-②)**:`single` 姿态下,普通成员通过数据门读 audit、history、commit 三张轨迹表会被拒绝。Studio 作者和平台管理员不受影响。 - **收紧**:去掉组织列后唯一性变严;旧库里新唯一索引拒收的重复行会被点名,不会被删除。 - **回滚**:revert 本 PR;代码不改写任何数据。 - **体量**:变更约 6,800 行,超过 3,000 行,属 Tier H。审批这一项已由 os-zhuang 的治理批准 5520987282 满足(#20153)。 **席位意见**:建议合入(ACCEPT 6184588618)。 - opus 合并复审 PASS 6184549574,三份 FAIL 记录里的阻塞项全部满足;CI 49 项全绿(42 通过、7 项按设计跳过),与当前 `main` 无冲突。 - 收紧(`single` 下普通成员读轨迹表被拒)是席位裁定的(E2 → A),changeset 已写明。 - 启动拒绝是 ADR-0131 D10 的硬性要求;升级顺序有端到端测试证明不会互相卡死。 - 复审列出的非阻塞项都只在未迁移的旧库上才会出现,而旧库本 PR 自己就拒绝启动,所以转成后续卡片 #23609,不阻塞合入。 - 未实测:dogfood 升级顺序测试在 PR CI 上不跑,首次运行在合并队列或 `main` 的全量测试;席位会盯首轮结果,红了立即修。 **你要做的**:回复「可以进队列」即可。席位随后通过 relay 把本 PR 转为 ready 并开启自动合并,由合并队列在全绿后合入。 ## What changed ### S2: the metadata family holds no organization column (ADR-0131 D6/D7) - **The four declarations** (`packages/metadata-core/src/objects/sys-metadata*.object.ts`): no `organization_id` field; `systemFields: { tenant: false }`. Indexes re-keyed: - `idx_sys_metadata_overlay_active` → (`type`, `name`, `package_id`, `bundle_member`); - history: unique (`event_seq`) and unique (`type`, `name`, `version`); its other indexes lose the organization prefix; - audit and commit lose the organization prefix; - `idx_sys_metadata_org_type` is dropped. - **The runtime overlay index** (`metadata-protocol/src/migrations/overlay-index.ts`): `OVERLAY_INDEX_COLUMNS` and the duplicate probe take the new key. - **No engine write names `organization_id` on the four tables**, because the engine refuses an undeclared field (`INVALID_FIELD`). The `organizationId` option on `SysMetadataRepositoryOptions` is removed. `DatabaseLoader` and `history-cleanup.ts` accept `organizationId` and ignore it, as they do `environmentId`. - **Legacy organization rows on an unmigrated (v17-shape) database are left out of every family read, per the dev's report 6184164699.** - `protocol.ts` reads the family through one seam, `findEnvironmentRows` / `findEnvironmentRow`. Each runs the engine read, then leaves out the rows that `readLegacyOrganizationRows` finds on the physical column, joining the write's transaction. - The by-name reads (`getMetaItem`, `getMetaItemLayered` via `findServedOverlayRow`), the item lock (`overlayLockLayerAt`) and the package-less address (`packagelessAddress`, including #23489's `packagelessReadAddress`) go through that seam. So do the list, draft, package, commit and history reads. - Two reads keep legacy rows on purpose and read the column themselves. `migrateStoredMetadata` reports such a row. `revertCommit` refuses a legacy commit by name. - The same filter covers `metadata`'s `DatabaseLoader`, `plugin-security`'s security-catalog echo and `service-automation`'s flow-credential migration. - A column read that fails throws `LegacyOrganizationRowsUnreadable` (code `LEGACY_ORGANIZATION_ROWS_UNREADABLE`). It is never taken for a migrated database. - `SqlDriver.listPhysicalColumns` is added, so the seam can ask whether the column exists. `isLegacyOrganizationRow` is removed. - **E2: `studio.access` gates `sys_metadata_audit`, `_history` and `_commit` in every posture** (seat answers 6171152049, 6179490127). Under `single`, a plain member's data-door read of these three tables was a bypass of what the `/meta` trail doors refuse. That bypass is closed. The `/meta` trail doors admitting `setup.access` is a separate card, #23546. - **The form doors' organization read is deleted:** `getMetaItems`' `legacyFormOrganizationId` and its callers in the REST form doors (seat 2's pointer 6165850696). - **The ceremony's family readers in `packages/cli`.** - The template lookup names the column only while it exists. - The overlay-duplicates category keys the bundle member. - `security-catalog-overlays` reads the physical column through the raw-SQL seam (`legacyOrganizationRowIds`), so an unmigrated database's organization control row is never listed or deleted. - **Four ADR-0087 column-retired semantic entries** (`18.sys-metadata{,-audit,-commit,-history}-organization-column-retired`), and the census (`scripts/platform-object-tenancy-census.json`): the four objects move out of reach. ### S5: the v18 boot refusal (ADR-0131 D10 item 5; ruling 6163114098, Q3 → A) - **`OrganizationOwnershipBootGuardPlugin`** (`packages/runtime/src/organization-ownership-boot-guard.ts`). At `kernel:ready` it asks whether a table whose object declares no organization column still carries a physical `organization_id`. - If one does, and the ceremony's `sys_migration` marker `adr-0131-organization-ownership` is not verified, it throws `OrganizationOwnershipBootRefusal` (code `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`). The refusal names the tables and the command, and stops `bootstrap()` before `kernel:listening`. - A fresh database boots. A database whose ceremony verified boots. - A failed column read is refused. - A driver with no physical columns (memory, MongoDB) is not checked. - It reads `SqlDriver.dialectName`, so the default `SqliteWasmDriver` is covered. - ⛔ There is no escape hatch: no variable and no flag skips the check (D10 verbatim). - **Composed by `os serve` only** (`packages/cli/src/commands/serve.ts`), beside `MigrationRecoveryPlugin`. It is never in `createStandaloneStack` or `data-migration-plugins.ts`. Those are the CLI data and schema boots, the ceremony's own `bootSchemaStack` among them, and a guard there would refuse the one command that clears it. - **E1: `os migrate apply` refuses `drop_column organization_id`** on any table in the ceremony's inventory (`ORGANIZATION_OWNERSHIP_INVENTORY`) while the marker is not verified. - It names `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`, and exits 1. Under `--json` it answers `refused_organization_ownership_unmigrated`. - The refusal holds the whole `--allow-destructive` run, as #17440 and #23281 do: nothing in it is created, applied or dropped. A run without `--allow-destructive` is unchanged. - The ceremony drops the columns itself, in its column-drops step, before it records the run verified. That step calls `stack.applyMigrationEntries` directly and is not gated. - **`ORGANIZATION_OWNERSHIP_MIGRATION_ID`** moves to `@objectstack/spec/system` (`migration.zod.ts`), so the runtime and the ceremony read one constant. ### Both - **Two new error codes** in `ERROR_CODE_LEDGER`: `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED` (`@objectstack/runtime`) and `LEGACY_ORGANIZATION_ROWS_UNREADABLE`. The published `ApiError.code` / `ErrorCode` enum widens by both, and the changeset names it. - **The spec reference docs are regenerated once over both codes** (`gen:schema && gen:docs`). `contract.mdx` reads `… +333 more` on `main` at `fa6a6085c0`. - **Two changesets travel**, both BREAKING under the launch-window `minor`. - S2's is `registered`, with the four ADR-0087 ids. - S5's is `already-registered`. - Every package in the fixed group publishes at `minor`. - `service-automation` is bumped. ## Landing - **ONE PR** (ruling 6180024026). S2 and S5 each register an error code and regenerate the same `contract.mdx` line, so two queued PRs would eject each other. S5 (`ba6d9c5de8`) is merged in as `ad23169210`, and #23461 is closed unmerged as superseded. - **Landing gate met:** #15211's S1 (#23452) is on `main` as `869a381dc9`, so the ceremony promotes before it drops (6163669810). - **Tier H by size** (over 3,000 changed lines). The approval leg is met by os-zhuang's governed approval 5520987282, which counts on any commit under #20153. The seat lands it through the merge queue after ACCEPT, and asks the maintainer first. ## Measured choices - **The history renumbering (Q2 → A) is not here.** S1 carries it, and S1 is on `main`. The build-ahead's copy (`5326201dff`) is reverted here (`4ba2ea3c`), not rewritten. - **S1's ceremony hunk copies are dropped.** Each shared hunk takes `main`'s S1 version, except the form-door organization read, which S2 deletes. - **Why the boot guard is its own plugin:** `MigrationRecoveryPlugin` is also composed by the CLI data-migration boot that the ceremony runs on, so a refusal inside it would refuse the ceremony itself. - **The upgrade order cannot deadlock.** The refusal fires at `kernel:ready`, after every plugin's `start()` and its additive schema sync, so S3's `sys_user_permission_set.permission_set` already exists when the ceremony runs. The dogfood pin below proves the order end to end. ## Pins - **S2's unmigrated-database pins** (`packages/cli/test/meta-stored-legacy-organization.integration.test.ts`): a real engine on a v17-shape SQLite database, with the column added raw, run on the ceremony's own `--apply` boot. - `door.save` of an item an organization holds bound to a package writes an environment row, and leaves the organization row untouched. - An organization row carrying `_lock: full` is not the item's lock. - The `kernel:ready` security-catalog echo resolves the environment's permission overlay, not the organization's. - The dev reports one ablation: `overlayLockLayerAt` put back on the raw read turns the lock pin red. - **E2:** the 14-case `studio.access` pin on the real SecurityPlugin middleware. - **S5's guard:** `organization-ownership-boot-guard.test.ts` (unit, real SQLite) and `organization-ownership-boot-guard.kernel.test.ts`, which runs on a real `ObjectKernel`. They cover: - a v17-shape database refused; - an unverified marker refused; - a verified marker, a migrated database and a fresh database booting; - a failed read refused; - no environment variable standing the refusal down. - **E1:** `apply-organization-ownership-gate.integration.test.ts`, real CLI, 5 cases: - refused with no marker, or with an unverified one; - an unrelated destructive drop on the same table proceeds; - a gated drop beside an unrelated drop in one run refuses both; - proceeds once the marker is verified. - **The upgrade order, end to end** (`organization-ownership-boot-refusal-order.dogfood.test.ts`): - a v17-shape file database is refused, naming the command; - the refused boot has already added `sys_user_permission_set.permission_set`; - `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`, verifies the marker with no boot in between; - the next boot succeeds. Dogfood runs on `main`'s full battery, not on PR CI. ## Acceptance notes - **Left for their own change (the dev's report 6184164699):** `rowsRead`, `ServedOverlayRowCandidate.scope` / `organizationId` and `orgId` / `itemOrgId` in `protocol.ts`. Every row that reaches them is now the environment's, so they are inert. Removing them changes the list merge's signatures across the file, a behaviour-neutral refactor. - **Turso remote on an unmigrated database: NOT MEASURED.** The remote arm of `listPhysicalColumns` was read, not run. - **Out of scope, carded:** - #23546: the `/meta` trail doors admit `setup.access`; - #23558: the ceremony's `dialectOf` / `resolvePlannedDriverExec` read `config.client` as a string only. ## Verification **Latest round, on head `e4811b67f2`** (the dev's report 6184164699, all in the foreground): | Package | Files | Tests | | --- | --- | --- | | `metadata-core` | 18 | 715 | | `metadata-protocol` | 235 (+3 skipped) | 20,533 | | `objectql` | 422 | 8,117 | | `rest` | 289 | 6,056 | | `runtime` | 386 | 6,449 | | `plugin-security` | 186 | 3,704 | | `service-automation` | 186 | 2,486 | | `service-datasource` | 45 | 838 | | `driver-sql` | 219 | 3,642 | | `metadata` | 62 | 888 | | `cli` unit | 290 | 4,324 | | `cli` integration (the full project) | 133 | 1,218 passed, 2 skipped | | dogfood (every file naming `sys_metadata` or the ceremony) | 29 | 222 | - Typecheck exit 0 in `metadata-protocol`, `metadata`, `service-automation`, `plugin-security`, `driver-turso` and `cli`. - `check:docs` and `spec check:generated` green; `check-i18n-bundles` OK. - `node scripts/pm/dispatch-gates.mjs --commands` derived 159 families, and all 159 ran; 154 exited 0. `check:test-source-alias` was red on the new pin's in-body import and was fixed by `e4811b67f2`. Four are NOT MEASURED, because they refused on a local prerequisite; CI runs them: - `check-engine-split-ratio` and `check-plugin-teardown-shape --self-test`: the shallow clone; - `check:skill-examples` and `check:dual-build-cjs-loads`: unbuilt packages. **Earlier rounds:** os-dev-reports 6168648434, 6170427033, 6179430370, 6181023527 and 6182409899 on #23429. **Contract reviews:** - S2: FAIL 6171128604 and FAIL 6181366782. - S5: PASS 6170906686 and FAIL 6180007547. - **The combined head at `e4811b67f2`: PASS 6184549574.** Its non-blocking items are carded as #23609. - Seat ACCEPT: 6184588618 on #23429. _Body rewritten for the one-PR form by `domain:engine` seat 1 (`session_01Rerax7QTjKMPCUZxQUtPFR`) from the dev's notes and #23461's body; the 维护者速读 was finalized after the PASS at 2026-10-15T04:43Z._ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 361c946 commit dd29bbe

143 files changed

Lines changed: 3823 additions & 3019 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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/metadata': minor
5+
'@objectstack/rest': minor
6+
'@objectstack/spec': minor
7+
'@objectstack/objectql': minor
8+
'@objectstack/plugin-security': minor
9+
'@objectstack/service-datasource': minor
10+
'@objectstack/service-automation': minor
11+
'@objectstack/platform-objects': minor
12+
'@objectstack/driver-sql': minor
13+
'@objectstack/driver-turso': minor
14+
'@objectstack/cli': patch
15+
---
16+
17+
feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7)
18+
19+
Clause-②: yes (narrowing)
20+
21+
<!-- adr-0087: registered sys-metadata-organization-column-retired, sys-metadata-audit-organization-column-retired, sys-metadata-commit-organization-column-retired, sys-metadata-history-organization-column-retired -->
22+
23+
**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes.
24+
25+
Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more.
26+
27+
FROM → TO:
28+
29+
- An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field.
30+
- `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows.
31+
- `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument.
32+
- `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`.
33+
- `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none".
34+
- An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database.
35+
36+
The indexes are re-keyed with the column:
37+
38+
- The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key.
39+
- `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix.
40+
- `idx_sys_metadata_org_type` is gone.
41+
42+
What else changes:
43+
44+
- The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run.
45+
- The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization.
46+
- Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after:
47+
- `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused.
48+
- The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused.
49+
- Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history` and `/audit` admit `studio.access`, `setup.access` or `manage_metadata`; `/packages/:id/commits` admits `studio.access` or `setup.access`. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `setup.access` or only `manage_metadata` therefore still reads these rows through `/meta`, but not through the data door.
50+
- `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization.
51+
- On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column:
52+
- every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too;
53+
- the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it;
54+
- `os migrate meta --stored` reports such a row `skipped` and never re-saves it;
55+
- `os migrate security-catalog-overlays` neither lists nor deletes it;
56+
- a commit recorded in a legacy organization's layer is still refused by the revert;
57+
- the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out.
58+
- `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists.
59+
- `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts.
60+
- The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/spec': minor
5+
---
6+
7+
feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape, and `os migrate apply` refuses to drop its `organization_id` columns, until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (already-registered sys-audit-log-organization-column-retired, sys-flow-dispatch-organization-column-retired, sys-job-organization-column-retired, sys-job-queue-organization-column-retired, sys-job-run-organization-column-retired, sys-migration-journal-organization-column-retired, sys-migration-organization-column-retired, sys-presence-organization-column-retired) The step-18 D3 entries named here, registered before this change, record the retirement of each organization column this refusal reads, with the remedy that clears it; the metadata family's four entries land with its schema change, which this refusal ships beside. This change adds no authorable surface, moves no spec key and rewrites no stored row: it refuses to boot while a retired column is still physically present, and the remedy is the operator's own migration: the ceremony, whose column-drops step drops what those entries prescribe dropping. No new D3 entry and no D2 conversion. -->
12+
13+
**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes.
14+
15+
A v18 runtime no longer serves a database it was never migrated for. At `kernel:ready`, `os serve` (and so `os dev`) checks every table whose object declares no organization column (`systemFields.tenant: false`). If one still carries a physical `organization_id`, the boot is refused unless the `sys_migration` row `adr-0131-organization-ownership` is verified. The refusal names the tables and the command. It is thrown before `kernel:listening`, so no request is served. Its code is `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`.
16+
17+
FROM → TO:
18+
19+
- A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply --plan-file FILE` with that plan file, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime.
20+
- `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table in the ceremony's inventory (the D7 tables and the `sys_metadata` family) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The refusal holds the WHOLE `--allow-destructive` run, as those two do: nothing in it is created, applied or dropped, every other destructive drop and the additive work included. A run without `--allow-destructive` is unchanged. The ceremony drops these columns itself, in its column-drops step, before it records the run verified.
21+
- There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check.
22+
23+
What does not change:
24+
25+
- A fresh database boots: no table it creates carries a retired column, so no marker is needed.
26+
- A database whose ceremony verified boots, whatever its columns.
27+
- The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only an `os migrate apply --allow-destructive` run that would drop an inventory table's `organization_id` is refused, as above.
28+
- A driver with no physical columns to read (memory, MongoDB) is not checked.
29+
- A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m
106106

107107
**Most metadata types must not be overlaid by an environment** — overlaying a packaged item would break the physical schema or the behaviour its package ships. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowEnvironmentOverlay` in `packages/spec/src/kernel/metadata-plugin.zod.ts` (spelled `allowOrgOverride` before protocol 18; `GET /api/v1/meta/types` serves it per entry as `allowEnvironmentOverlay`).
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`). 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.
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** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) 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, and an 18.x `os serve` refuses to start on a database still holding one until the v18 migration ceremony (ADR-0131 C7, `os migrate organization-ownership --apply`) has carried it to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item.
110110

111111
| Type | `allowEnvironmentOverlay` | Rationale |
112112
| :--- | :---: | :--- |
@@ -234,7 +234,7 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi
234234
|:---|:---|:---|
235235
| `defineView(...)` / `defineFlow(...)` / any source file → compiled into `dist/objectstack.json` | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history *is* Git. The metadata layer does not duplicate it. |
236236
| Editing a `.json` under `<root>/<type>/<name>.json` (FS overlay, e.g. `<root>/view/case_grid.json`) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at `<root>/.objectstack/.log/main.jsonl` by `FileSystemRepository`. |
237-
| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowEnvironmentOverlay: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row**, environment-wide (`organization_id` NULL) since ADR-0131 D6. | ✅ Appended to `sys_metadata_history` in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. |
237+
| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowEnvironmentOverlay: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row**, environment-wide since ADR-0131 D6 (the table declares no organization column since D7). | ✅ Appended to `sys_metadata_history` in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. |
238238
| Deploying a new build (new `dist/objectstack.json`) | ❌ The artifact is loaded into memory, not synced into `sys_metadata`. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. |
239239

240240
### Why artifact never enters the database

0 commit comments

Comments
 (0)