Skip to content

feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) - #23469

Merged
objectstack-fleet[bot] merged 52 commits into
mainfrom
claude/issue-23429-metadata-family
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 52 commits into
mainfrom
claude/issue-23429-metadata-family

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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 满足(governed guard: an authorized APPROVED review lifts the >5000-line SIZE limb, as it already lifts Tier H paths #20153)。

席位意见:建议合入(ACCEPT 6184588618)。

你要做的:回复「可以进队列」即可。席位随后通过 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 fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #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, rest/runtime: under a wall, the /meta history, audit and package-commits doors admit setup.access, which organization admins hold, so an organization admin reads the environment-wide authoring trail of any item #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.
  • 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

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

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:

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.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR

claude added 30 commits October 14, 2026 07:45
…eclares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2)

WIP: the engine reads and writes that still name organization_id on these
four tables are not yet changed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…ization_id on the sys_metadata family (C7b S2)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…organization read, byte-identical (C7b S2)

The undeclared organization column makes that read inexpressible, so S2
carries the same hunks S1 lands first; the overlap merges clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…y; legacy uninstall path and legacy-row report deleted (C7b S2)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…s stop naming organization_id (C7b S2)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…shape database until the ownership ceremony is verified (ADR-0131 D10 item 5, C7b S5)

WIP: tests not yet run.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
… unique environment-wide before column-drops (seat answer Q2 A, C7b S2)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…on claude/issue-15211-c7b-promotions)

The card (#23429) places the Q2 A renumbering in #15211 S1, and S1's branch
carries it. S2 holds only the family's schema and its readers.

This reverts commit 5326201.

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…tadata-family

# Conflicts:
#	packages/metadata-protocol/src/sys-metadata-repository.ts
…stop naming organization_id; the last protocol filter goes (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…ation-scoped stored rows (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…op seeding legacy organization rows (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…e environment's rows only (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…ow the organization-free family; the legacy-row boot report's pin goes with it (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…ng legacy organization rows (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…ry's retired organizationId option; flow, audit and store pins follow (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…pins drop their organization legs (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…s left unused (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…tion scope (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…on-free family (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…ly's changeset (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
… the form door reads one view layer (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…rified marker and a fresh database boot (ADR-0131 D10 item 5, C7b S5)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…-free family and the boot refusal (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…e docs for the boot refusal; changeset wording (C7b S5)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…anization rows (C7b S2)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…ude/issue-23429-metadata-family

No textual conflict on this merge. The generated reference docs are
regenerated over the merged ledger in the next commit, never picked from
a side.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
)

`gen:schema && gen:docs` on the merged tree: `… +333 more` (main's four
API_KEY_* codes from #23541, plus ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED
and LEGACY_ORGANIZATION_ROWS_UNREADABLE). check:docs green; the i18n
bundles are in sync (check-i18n-bundles OK, nothing to regenerate).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
…of every family read (#23429)

S2 re-review 6181366782, blocking fix 2. On a v17-shape database (which
`os serve` refuses but the ceremony's own boot reads), these still served,
locked, addressed or echoed a legacy organization's row as the
environment's:

- protocol: every sys_metadata / _audit / _commit / _history read now goes
  through findEnvironmentRows / findEnvironmentRow (the physical column via
  readLegacyOrganizationRows, joining context.transaction). That covers
  findServedOverlayRow (getMetaItem, getMetaItemLayered), overlayLockLayerAt
  (the item lock, lockWriteRefusal, assertLockAllowsDelete,
  authoredLockFamily), packagelessAddress (save, delete, readVersionToken,
  packagelessReadAddress) and every other find the record lists. Two keep
  legacy rows on purpose and read the column themselves: migrateStoredMetadata
  and revertCommit's commit read. Stale `organization_id null` comments fixed.
- plugin-security security-catalog-echo and service-automation
  flow-credential-migration: the dead `row.organization_id` discriminators
  read the physical column.
- metadata DatabaseLoader: its family reads leave legacy rows out, engine-
  or driver-backed.
- Real-engine pins (cli, v17-shape SQLite, column added raw): a package-bound
  organization row never becomes the package-less address; an organization
  row carrying `_lock: full` is neither the item's lock (lock.verdict) nor the
  gate's refusal; the kernel:ready echo registers the environment's
  permission overlay and never the organization's. Ablation: overlayLockLayerAt
  back on the raw read turns the lock pin red; restored.
- Non-blocking: es-ES/ja-JP/zh-CN environment_id help re-translated;
  TursoDriver's introspectSchema JSDoc re-attached and the remote catalog
  read's errors go through rawStatementFault; the changeset names the
  ApiError.code widening and service-automation; the repository's history
  reads and the tenant-chokepoint gate's not-covered note are documented.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
…odule load (check:test-source-alias) (#23429)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Served on opus under the maintainer's order 6166448512 (「后续契约复审用 opus就够了」, seat note on #6367).
Head-sha: e4811b67f2d83c4a0a45708ac9b4a769733d5d36
Local-runs: none

Scope. This is one re-review of the combined S2 + S5 head (ruling 6180024026). It covers:

  • S2, against FAIL 6171128604 and FAIL 6181366782;
  • S5's delta after ba6d9c5de8, against FAIL 6180007547 and PASS 6170906686.

What was read.

Gate verdicts: 49 check-runs, re-read at 04:36Z after the last one finished. 42 succeeded, 7 were skipped, none failed and none is pending.

  • Green:
    • Test Core (1/6) to (6/6), and the aggregate Test Core;
    • every Type Check lane, including source gates and TypeScript Type Check;
    • Build Core and Build Docs;
    • every Lint lane, including PM dispatch-gates;
    • Check Changeset, both on the push and on the 04:11Z body-edit rerun;
    • Governed Surface Queue Guard, Spec property liveness and Validate Package Dependencies;
    • the claim and Part-of guards.
  • Skipped by design on pull_request:
    • Dogfood Regression Gate shards, Dogfood Verify CLI, Console Pin Gate, Temporal Conformance (live PG + MySQL) and Packed-tarball smoke;
    • Auto Label and Check PR Size on the body-edit rerun only. Both were green on the push run.
  • So no PR run exercised the dogfood pins, PostgreSQL or MySQL.
  • These verdicts are verdicts on the landing tree. GitHub's merge ref 41ef8fa949 merges the head into main a6d0715535. Its tree, 4cccda9886, is byte-equal to git merge-tree --write-tree origin/main e4811b67f2.

The landing tree.

① Derived judgments

1. B1: met.

  • The head contains main at fa6a6085c0 through the merge 7d8039aea6.
  • The dev's "no textual conflict" is not what git records, in either report (6184164699 for this merge, 6182409899 for the S5 merge ad23169210).
    • Re-running both merges conflicts in contract.mdx, on the count line: +329 against +331, and at the S5 merge, +328 against +328.
    • Each merge commit took the branch side of both generated mdx files whole. At 7d8039aea6 that also dropped main's four API_KEY_* bullets.
    • 7c0b58abc0 and a62b26fb32 then regenerated both files.
  • The result is generated, not hand-picked.
    • The head's contract.mdx enumerates 340 codes (7 + … +333 more). main's enumerates 338 (+331).
    • The difference is exactly LEGACY_ORGANIZATION_ROWS_UNREADABLE and ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED. error-code-ledger.mdx lists both.
  • On the landing tree:
    • None of main's 22 commits touches error-code-ledger.zod.ts, contract.mdx, error-code-ledger.mdx or any ErrorCode source.
    • "Check generated reference docs are in sync with the spec" (Type Check · source gates, step 28) is green on the merge ref with a6d0715535.
    • So check:docs passes in the merge group, unless main adds an error code before the queue builds.

2. B2: met. No reader whose organization_id predicate S2 removed still treats an organization's row as the environment's on a v17-shape database.

The seam (protocol.ts:10241-10273):

  • findEnvironmentRows runs engine.find, then filters through legacyOrganizationRowsOf.
  • findEnvironmentRow runs engine.findOne. Only when that row is a legacy organization's does it run engine.find with the same query, and returns the first non-legacy row, or null.

protocol.ts, enumerated on the landing tree (every engine read of the four tables). 26 call sites in 23 functions go through the seam:

  • foldStoredCollection;
  • readFlattenedMetaItems (draft preview, ×2);
  • readActiveOverlayRows (×2);
  • findServedOverlayRow (:10599);
  • overlayLockLayerAt (:10635);
  • auditMetaItem;
  • storedFlowBindingAgrees;
  • packagelessAddress (:18997);
  • restoreArtifactRegistryView;
  • resolveOverlayPackageBinding;
  • metaTypeNamespaceExists;
  • promoteDraftForPublish;
  • publishPackageDrafts;
  • revertStoredPackage;
  • deletePackage;
  • duplicatePackage;
  • unmanagedImportCollisions;
  • reassignOrphanedMetadata;
  • listCommits;
  • rollbackToPackageCommit;
  • diffMetaItem (history and current, ×2);
  • deleteMetaItem;
  • loadMetaFromDb.

How the record's five readers are covered:

  • The callers of findServedOverlayRow, overlayLockLayerAt and packagelessAddress are unchanged. The callers are getMetaItem, getMetaItemLayered, lockWriteRefusal, readLockGateOverlayLayer, authoredLockFamily, the save and delete paths, readVersionToken and packagelessReadAddress.
  • Every protocol write to the family is either an insert or an update or delete by the id of a row read through the seam.

Reads outside the seam:

  • The two deliberate exceptions, migrateStoredMetadata (:23393) and revertCommit (:27764).
  • The generic data door (/data/sys_metadata*, the request.object reads at about :14182-15353). It reads any object unfiltered. It is reached only through os serve, which S5 refuses on such a database.

The other packages the brief names.

  • metadata DatabaseLoader:
    • _find and _findOne filter, and _findOne re-reads through the filtered _find.
    • _count does not filter. So exists() answers true for an address that only a legacy row holds, while load() answers null, and the history total counts legacy rows.
    • This is pre-existing: no production caller of setDataEngine or setDatabaseDriver on main passes organizationId, so main's loader never filtered either. Nothing is persisted. Non-blocking.
  • plugin-security:
    • security-catalog-echo reads the column only when rows came back. A failed read takes the existing could-not-read branch and echoes nothing of that type.
    • packaged-permission-set-overlay-detection reads the column.
    • security-plugin.ts:9212 probeUnpublishedDraft only chooses wording. It is documented as not organization-scoped, and is unchanged.
  • service-automation:
    • flow-credential-migration reads the column, and reports NOT_OVERRIDABLE with the organization taken from it.
    • flow-credential-channel.ts:496 storedFlowStates never discriminated on main. A legacy row can keep a deleted environment flow's channel rows: that is retention, not exposure.
  • objectql: all three binders in plugin.ts read the column.
  • service-datasource: findEnvironmentRows reads the column.
  • rest: no direct family read is left. walledCatalogRefusal only names the table, and the form-door read is deleted.
  • cli:
    • security-catalog-overlays uses the shared read.
    • The ceremony files read the physical column in raw SQL, on main's code.
    • secret-reference-union.ts:569 reads every datasource row. It never discriminated, and a superset is safe for a reference check.
  • Outside the brief's list:
    • core authored-translation-sync.ts:125 builds an in-memory authored translation layer and never discriminated on main.
    • plugin-audit stored-metadata-body-migration reads by id.
    • Both are unchanged by this PR and pre-existing. Non-blocking.

The repository (sys-metadata-repository.ts):

  • get, the draft-inherit read, put, delete, promoteDraft, the active read, list and listDrafts go through its own findEnvironmentRow or withoutLegacyOrganizationRows.

  • The history reads (getByHash, restoreVersion, history(), the watch replay, nextEventSeq, nextVersion) read every lineage, as the code comment states:

    • getByHash is content-addressed;
    • restoreVersion, history and watch are reached only through os serve's doors;
    • the counters can only raise the next number, and they must count every lineage, or the new unique (event_seq) and (type, name, version) indexes would collide.

    Right.

The two deliberate exceptions: right.

  • migrateStoredMetadata has to see a legacy row to report it skipped with its organization. It reads the column once per pass.
  • revertCommit reads the commit by its primary key and refuses a legacy commit by name.
  • Non-blocking: rollbackToPackageCommit goes through the seam, so a legacy commit there answers COMMIT_NOT_FOUND (404), not the named refusal. That door is served by os serve only.

The single-row form's re-read past a legacy hit: correct.

  • Paging: no call site passes limit, fields or orderBy (all 12 protocol sites read). ObjectQL's find adds no default limit and SqlDriver sets none, so the re-read sees every row at the address. id is always projected.
  • Ordering: it returns the first environment row in store order, which is findOne's own unordered semantics.
  • Termination: at most two engine reads and one column read, with no loop.
  • The same holds for the repository's copy and for DatabaseLoader._findOne.

The plural form filters after limit. Each of these fails toward fewer rows, never toward an organization's row, and all are served-door paths. Non-blocking:

  • auditMetaItem (orderBy plus limit) and listCommits (limit) under-fill a page;
  • revertStoredPackage (limit: 1) answers 409 "never been published" when its first active row is a legacy one;
  • the repository's list(filter.limit) under-fills.

The transaction join is explicit only.

  • The repository: withTxn threads ctx, so the column statements join the write's transaction (the dev's template-promotion pins run through it).
  • The protocol:
    • Every protocol call site passes { isSystem: true } and no transaction. legacyOrganizationRowsOf therefore never receives one from the protocol.
    • ObjectQL's ambient txStore makes the engine reads join on their own. The seam's raw listPhysicalColumns and execute calls do not read txStore.
  • Where that matters: inside publishPackageDrafts' ambient engine.transaction, these reads would take a second pool connection on a v17-shape SQLite file, which waits on the single connection the transaction holds:
    • promoteDraftForPublish's draft read (:24251);
    • the active-row read (:25320);
    • repo.get and repo.promoteDraft, whose contexts are { isSystem: true }.
  • Why it is latent:
    • It is reached only through the package-publish doors, which os serve serves and S5 refuses on such a database.
    • On a migrated or fresh store, loadMetaFromDb primes the absent verdict, so no raw statement runs.
    • Non-blocking. The body's "joining the write's transaction" holds for the repository's own write transaction only.

Fails closed: yes.

  • LegacyOrganizationRowsUnreadable is thrown when:
    • the seam returns no result set;
    • the dialect is unresolved;
    • there is no raw seam;
    • listPhysicalColumns returns something that is not a list;
    • the column list is empty;
    • the table is outside the family.
  • A driver error from listPhysicalColumns or execute propagates as itself. It is still a refusal, though not under the registered code.
  • rethrowUnlessMetadataStoreUnprovisioned rethrows it. The echo, the flow move and the binders take their could-not-read branches, and none of them registers or saves anything.
  • Open edges, unchanged and non-blocking:
    • a missing getDriverForObject, or an undefined driver, reads as "not SQL" and returns the empty map;
    • TursoDriver's remote arm maps a row without name to '', which reads, and is cached, as "no organization column". NOT MEASURED.

3. B2 pins (meta-stored-legacy-organization.integration.test.ts, 7 cases). Judged by reading; nothing was run.

  • (1) The package-bound organization row through door.save: discriminates. Without the packagelessAddress filter, the address becomes com.example.m23429. The insert then lands beside the organization's row on the re-keyed unique index, the save is refused, and toBeNull() goes red.
  • (2) The _lock organization row: the save leg discriminates the gate. The dev's ablation on overlayLockLayerAt went red; I did not re-run it.
    • The getMetaItem leg asserts only lock and editable.
    • With findServedOverlayRow alone put back on the raw read, getMetaItem would serve the organization's body as item, and the lock would still come from the fixed overlayLockLayerAt. That leg stays green.
    • No pin in the file fails when findServedOverlayRow's filter alone is removed. The code is right by reading. Non-blocking.
  • (3) The kernel:ready echo: a joint pin.
    • It fails only when both the echo's filter and findServedOverlayRow's are put back. An organization-only name the echo collects resolves to no overlay through the fixed by-name read.
    • The echo's own filter, and flow-credential-migration's, are each pinned independently by their unit tests: security-catalog-echo.test.ts and flow-credential-migration.test.ts. Their engine doubles strip organization_id, and the column sits behind getDriverForObject.
  • The changeset's by-name line is now true.

4. S5 on the combined head: holds.

5. The earlier S2 items from 6171128604 still hold.

  • metadata-core is unchanged since 72c972e690 (git diff --stat empty): the four declarations, systemFields.tenant: false, the helper, and studio.access on the three trail tables, with sys_metadata ungated.
  • Also unchanged since 72c972e690:
    • overlay-index.ts and the index re-keys;
    • the four ADR-0087 entries, whose ids equal the marker's;
    • the census (48→44 in reach, 35→39 out of reach);
    • the 14-case E2 pin (1 + 3×4 + 1).
  • The form-door deletion holds. legacyFormOrganizationId survives only in tests, CHANGELOGs and .changeset/pre. isLegacyOrganizationRow survives only as a doc mention.
  • SqlDriver.listPhysicalColumns is unchanged. On the Turso remote arm, errors now go through rawStatementFault and the introspectSchema JSDoc is re-attached.
  • check:tenant-chokepoint: its not-covered note now names the catalog read. That is a comment, and the gate is green.

6. The dogfood census: no change.

  • The only census-pinned file this PR touches is rest-server.ts. On every control term, main and the landing tree read the same:
    • enforceAuth: 53 in the file, plus data-action-routes.ts, which is untouched, for 64;
    • this.routeManager.register(: 54;
    • registerPerItemRoute(: 8;
    • const registerPerItemRoute =: 1;
    • private register*Endpoints(: 16.
  • The PR's hunks sit at lines 1918 and 9789-9865, away from the forwarder slice at 5798.
  • No dispatcher or domains/ file is touched.
  • main's scheduled run keeps 64.

7. Public-surface and accept-set changes, each judged against the changesets:

  1. The four family tables lose organization_id; tenant: false; uniqueness tightens; idx_sys_metadata_org_type is gone. Named, right.
  2. studio.access on audit, history and commit. Under single, a plain member is refused. Named, right.
  3. getMetaItems drops legacyFormOrganizationId, and the form doors read the environment alone. Named, right.
  4. SysMetadataRepositoryOptions.organizationId is removed. DatabaseLoader's and MetadataManager's organizationId is ignored. Named, right.
  5. isLegacyOrganizationRow is removed; readLegacyOrganizationRows and LegacyOrganizationRowsUnreadable are added. Named, right.
  6. ApiError.code / ErrorCode widens by LEGACY_ORGANIZATION_ROWS_UNREADABLE. Named, right, in S2's spec line.
  7. SqlDriver.listPhysicalColumns and the TursoDriver override. Named, right.
  8. On an unmigrated database, the family reads leave legacy rows out or refuse. Named, right, by-name line included. Slight overreach: "every read the metadata protocol makes of the family" does not cover the generic /data door, which os serve serves.
  9. The os serve refusal, ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, ORGANIZATION_OWNERSHIP_MIGRATION_ID in spec/system, the guard and assertOrganizationOwnershipMigrated. Named, right.
    • S5's changeset does not state the ApiError.code widening by its code.
    • Five of the seven runtime exports are unnamed: OrganizationOwnershipBootRefusal, ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, findV17ShapeTables, isOrganizationOwnershipBootRefusal and organizationOwnershipRefusalMessage.
    • Both are non-blocking.
  10. os migrate apply --allow-destructive holds the whole run, with message: 'refused_organization_ownership_unmigrated'. Named, right.
  11. Additive and unnamed: FlowCredentialMigrationEngine.getDriverForObject?. Non-blocking.

8. Size and tier.

  • check-governed-merges --pr 23469 refused: page 2 of the changed-files read answered HTTP 403.
  • --branch e4811b67f2 --root (this clone, three-dot from fa6a6085c0) gives:
    • 143 paths, 0 of them on the register;
    • +3,823 / −3,019, so 6,842 changed lines, which matches GitHub's counts.
  • That is HUMAN MERGE OR AUTHORIZED APPROVAL (Tier H by size).
  • The governed approval is verified: review 5520987282, by os-zhuang, APPROVED on 1183327b11 at 2026-10-14T16:10:56Z.

② Semver level

The levels.

  • S2's changeset: minor for metadata-core, metadata-protocol, metadata, rest, spec, objectql, plugin-security, service-datasource, service-automation, platform-objects, driver-sql and driver-turso; patch for cli.
  • S5's changeset: minor for runtime, cli and spec.
  • So cli publishes at minor. Every one of these packages is in the single fixed group, so everything publishes at minor.
  • Every published package with a changed source file is named. The dogfood package is private.

BREAKING, markers and placement.

  • BREAKING is stated in both changesets, under the launch-window minor.
  • ADR-0087 markers:
    • S2: registered, with the four ids, which equal the entries' ids.
    • S5: not-required (already-registered …). All eight step-18 ids exist on the landing tree, and its sentence about the family's entries is now literally true.
  • Nothing is under content/docs/releases/, and no CHANGELOG.md is touched.
  • Check Changeset is green on both runs.

Clause-②: yes (narrowing)

③ Boundary flags

6167186090.

  • Q1 (the history renumbering): it is S1's and on main.
  • Q2 (the form doors): the hunks are this PR's own.
  • Q3 (S5's site): A, judged right in 6170906686.
  • Q4 (the cli files): A.
  • Q5 (S5's marker): A. Its own registrations ride in the same PR.
  • Out-of-scope isLegacyOrganizationRow: resolved.
  • The dialectOf / config.client finding: still no carrier is named in any input. It stays escalated to the seat, as in 6181366782. Non-blocking.

6168648434.

  • Q1 (the bodies): the seat rewrote the body at 04:11Z, and it opens with Fixes #23429.
  • Q2 (S5's end-to-end pin): superseded by the one-PR form.

6170427033 and 6172828957. The body notes are the seat's; nothing is open.

6179430370.

6181023527. The per-hunk claims check out. "No overlapping hunk" in protocol.ts is now true semantically too: the B2 seam.

6182409899.

  • "NO textual conflict" at ad23169210 is wrong: contract.mdx conflicted, and 7c0b58abc0's regeneration settled it.
  • Fixes 2 and 3, the optional pin 1c and the changeset claims are verified.

6184164699.

  • b1_mergeable's "no textual conflict" is wrong in the same way. The end state is generated (①1).
  • b2_one_seam, b2_the_five and b2_every_other_find_listed are verified, with the limits in ①2:
    • the transaction join is explicit only;
    • filtering happens after limit;
    • DatabaseLoader._count does not filter;
    • flow-credential-channel and authored-translation-sync never discriminated.
  • b2_pins is verified with the gap in ①3: findServedOverlayRow is unpinned on its own.
  • changeset is verified.
  • non_blocking, done and verified:
    • the es-ES, ja-JP and zh-CN environment_id help;
    • the Turso JSDoc and rawStatementFault;
    • the history-read comment;
    • the chokepoint note.
  • non_blocking, "NOT done: rowsRead / ServedOverlayRowCandidate.scope / orgId plumbing, inert": verified inert.
    • servedOverlayRowCandidates only ever builds { scope: 'env', organizationId: null } (protocol.ts:2555).
    • itemOrgId is the constant null (:27927).
    • rowsRead is typed as a record with no admissible keys (:2682).
    • Removing them is a behaviour-neutral refactor that belongs in its own change. Accepted as non-blocking.

Seat answers.

NOT MEASURED:

Implemented-by: claude/issue-23429-metadata-family
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS

Non-blocking:

  1. Pin findServedOverlayRow alone. In the _lock case, assert that read.item is not the organization's body. As written, no pin goes red when that filter alone is removed.
  2. The seam joins only an explicit context.transaction. Have legacyOrganizationRowsOf take the engine's ambient transaction too, or thread trxCtx into the reads inside publishPackageDrafts. Today it is latent: the paths are served only by os serve, and the absent verdict is cached on migrated databases.
  3. Filtering after limit under-fills or misreads in four places: auditMetaItem, listCommits, revertStoredPackage's limit: 1 (a 409) and the repository's list. Filter before limiting, or page.
  4. DatabaseLoader._count (exists(), the history total) counts legacy rows. This is pre-existing.
  5. rollbackToPackageCommit answers 404 for a legacy commit, where revertCommit gives a named refusal.
  6. Wording:
    • The four ADR-0087 entries, and metadata-lifecycle.mdx, name os migrate organization-ownership --apply without --plan-file. main's precedent and the 18.cli-organization-ownership-apply-plan-file-required entry cover it.
    • E1's refusal sentence "which the organization-ownership ceremony owns that drop" is garbled.
  7. Changesets:
    • S5's should state the ApiError.code widening by ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, and name its five other runtime exports.
    • S2's "every read the metadata protocol makes" could except the generic /data door.
  8. The dev's reports misstate both merges as conflict-free. The end state is regenerated and correct.
  9. The open escalation: dialectOf / config.client needs a carrier.
  10. Before the queue:

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 06:15
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 06:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit dd29bbe Oct 15, 2026
57 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23429-metadata-family branch October 15, 2026 06:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

3 participants