Skip to content

fix(metadata-protocol): an org-scoped overlay row no longer reaches the process-wide SchemaRegistry (#6602) - #6779

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6602-org-overlay-registry-gate
Aug 8, 2026
Merged

fix(metadata-protocol): an org-scoped overlay row no longer reaches the process-wide SchemaRegistry (#6602)#6779
os-zhuang merged 2 commits into
mainfrom
claude/issue-6602-org-overlay-registry-gate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6602

The invariant, and where it was not enforced

ADR-0005 (revised 2026-05) says only env-wide rows (organization_id IS NULL) enter the process-wide SchemaRegistry; per-org overlays are served on demand and never grafted into the registry every org in the process shares. The registry holds exactly one plain key per (type, name) and has no org dimension to keep two orgs' bodies apart, so a per-org body sitting under that key is the other orgs' body.

The repo already stated the rule twice and enforced it once:

  • loadMetaFromDb filters organization_id: null and says why in its own comment — "Per-org overlays are loaded on demand by getMetaItem to avoid cross-org leakage into the process-wide SchemaRegistry."
  • applyRegistryWriteThrough's TSDoc claimed it too — "a project-scoped row must not be registered into a registry that unscoped (control-plane) callers share. The write must not be more permissive about that than the read is." — while the code was one line, if (this.environmentId !== undefined) return;, which says nothing about organization_id.

Both runtime seams were therefore org-blind:

  1. The write-through. On an unscoped kernel a per-org view write hydrated straight into the registry under the plain key. view is legitimately allowOrgOverride: true, so this is the designed per-org overlay leaking out of its org; flow reaches the same seam through the runtime-create tier.
  2. The read hydration. getMetaItems merges this caller's orgRecords into overlays and then hydrated the whole merged set under the same environmentId === undefined gate. One org-scoped listing call grafted that org's bodies, and would have undone a write-side-only fix at the very next listing.

Premise verified on origin/main before implementing, not taken from the card: the new pin file measured 10 red / 7 green against unfixed code, reproducing the issue's PROBE P3 exactly.

The fix

hydrateOverlayIntoRegistry is the ONE choke point all three hydration callers already route through since #4521 (boot, read-side, write-through). The row-scope verdict now lives there:

private hydrateOverlayIntoRegistry(
    type: string,
    data: unknown,
    options: { packageId?: string | null; organizationId: string | null },
): boolean {
    if (options.organizationId !== null && options.organizationId !== undefined) return false;
    ...

organizationId is required, not optional — and that is the design, not a style choice. An optional org would default to "env-wide" on omission, which is precisely the hole being closed; a required one makes every caller state the row's scope in order to compile, so a fourth hydration caller cannot arrive without a verdict. Declared = enforced, at the seam rather than in each consumer.

The kernel-scope gate (environmentId === undefined) deliberately stays with the callers: that is a fact about the kernel this protocol instance serves, not about the row in hand. The two verdicts are different questions and now live at the levels that can answer them.

Caller side:

  • applyRegistryWriteThrough takes organizationId and passes it down. All three call sites hand it the same orgId the row was written withsaveMetaItem (request.organizationId ?? null), runPublishSideEffects (args.orgId), rollbackMetaItem (request.organizationId ?? null) — so the registry's view cannot disagree with the row's scope.
  • The getMetaItems hydration loop carries each row's own organization_id alongside its package_id and judges per row. The merged listing is untouched: org readers still get their overlays.
  • loadMetaFromDb passes record.organization_id too. That is a no-op today by construction, and that is the point — the rule its comment states stops depending on a WHERE clause staying correct.

Deliberately NOT changed

  • What org readers see. The merged listing, getMetaItem's org-preferred on-demand read, and the org-scoped write itself are untouched. This closes a registry leak, never a write or a read; per-org overlays keep working exactly as ADR-0005 designed them.
  • meta overlay: a just-saved overlay is listed but not dispatchable for a short window — a cache between saveMeta and resolveRouteActionDeclaration lags the write #4521 read-your-writes. An env-wide save is still dispatchable the moment it lands, with no listing call in between (pinned).
  • The object branch. An object is allowOrgOverride: false and its physical table is env-wide, so the registry entry backing it is env-wide too. assertObjectRegistered fails closed on a missing entry, so gating that branch would make a runtime-created object unreachable for data CRUD rather than merely un-listed. That branch has never carried the environmentId gate either, for the same reason. The reasoning is now written into the code at the branch.

Delete side — reconciled, with one honest correction

Per the issue's binding point 1: this fix keeps the plain-key model and refuses org-scoped rows at both entry seams, so restoreArtifactRegistryView / removeRuntimeShadow / removeOverlayEntry need no re-keying. Verified in both directions, and pinned:

Where the card's prediction needs one correction, measured rather than assumed. "Nothing org-scoped to mis-delete" is true of org-scoped entries; it does not cover the other direction — an org-scoped delete operation still addresses the env-wide entry, because restoreArtifactRegistryView(type, name) is org-blind. Probed on this branch:

env-wide `view/shared_grid` saved  -> registry entry = "Env grid"
org A saves its overlay of the same name -> registry entry = "Env grid"   (this fix working)
org A DELETES its own overlay             -> registry entry = undefined   (the env-wide entry is gone)

That is pre-existing and independent of this PR — before this fix the env-wide entry was already destroyed on that path, just one step earlier (org A's write overwrote it, then org A's delete removed it). This change neither creates nor worsens it, and fixing it is a delete-chain change outside this issue's file surface, so it is filed separately rather than smuggled in here.

Tests

New pin file packages/objectql/src/protocol-org-overlay-registry-gate.test.ts — placed in objectql because that is where the protocol-plus-real-SchemaRegistry seam suites already live (protocol-boot-hydration-scoped.test.ts, protocol-registry-shadow.test.ts), and the disclosure shape only reads true against the real registry. Its fake engine routes both destructive verbs through the producer's own predicates (assertEngineDeleteDispatch / assertEngineUpdateDispatch).

18 cases: the premise read from DEFAULT_METADATA_TYPE_REGISTRY rather than restated; the write seam (view + flow tiers); the read seam; the end-to-end disclosure shape (org B's listing never contains org A's item, both write-then-list and org-A-lists-first); the on-demand per-org read still serving org readers; and the delete-side reconciliation above.

One case earned its keep by failing my own presumption and is written down as measured fact instead of repaired into agreement: an org-scoped listing hydrates nothing for a name its org overlays, because the merge collapses env-wide and org rows by (package, name) with the org row winning, so the shadowed env-wide row is not in the set the loop walks at all. The subtraction is the leak only — pre-fix that listing hydrated org A's body, never the env-wide one — and the env-wide entry arrives from boot or the unscoped read, which is pinned separately.

Reverse verification — direction predicted BEFORE running

Ordinary red with deliberately green controls. Prediction, written before the run: deleting only the organizationId refusal restores the org-blind seam and turns 11 red / 7 green, the 7 green being the env-wide controls (no org to refuse) plus the two getMetaItem cases (that path never hydrates at all).

Measured: 11 red / 7 green, and the red set was exactly the predicted set.

Note the write-side seam admits no separate reverse test any more: with organizationId required, "the caller forgets to pass the org" is not expressible. That is the structural point of the required parameter, stated here rather than faked as a second experiment.

Commands run

pnpm --filter @objectstack/objectql exec vitest run src/protocol-org-overlay-registry-gate.test.ts
  Test Files  1 passed (1)        Tests  18 passed (18)

pnpm --filter @objectstack/metadata-protocol test
  Test Files  58 passed (58)      Tests  627 passed (627)

pnpm --filter @objectstack/objectql test
  Test Files  150 passed (150)    Tests  2567 passed (2567)

pnpm --filter @objectstack/runtime test
  Test Files  114 passed (114)    Tests  1702 passed (1702)

turbo run typecheck --filter='./packages/*' --filter='./packages/*/*'
  Tasks: 119 successful, 119 total

pnpm check:type-check-debt
  OK -- 34 ledger entr(ies) re-measured, none above its recorded number

Every gate step enumerated from .github/workflows/lint.yml was run one by one in the foreground: lint, check:slot-lookup, check:query-options-erasure, check:verify-stand-in, check:nul-bytes, check:doc-authoring, check:docs-audit-scope, check:role-word, check:quick-reference-counts, check:adr-anchors, check:org-identifier, check:authz-resolver, check:service-providers, check:route-envelope, check:error-code-casing, check:wildcard-fallthrough, check:meta-type-normalized, check:init-service-contract, check:durability-log-level, check:startup-registry-verdict, check:objectui-changeset, check:release-notes, check:release-body, check:node-version, check:workflow-status-functions, check:shard-attestation, check:published-files, check:engine-double-contract, check:kernel-hook-pairs, check:resume-authority-declared, check:driver-memory-census, check:merge-driver, check:spec-parsed-alias — all pass.

The first pass of the objectql TEST_DEBT ratchet went +2 on the new file (two TS2559 from one over-narrow test helper). Fixed at the source rather than by raising the ledger; the ratchet now reports nothing above its recorded number.

Successor pricing

中文摘要

ADR-0005 规定只有 env-wide 行(organization_id IS NULL)才进入进程级 SchemaRegistry,per-org overlay 按需服务、绝不嫁接进共享注册表。冷启动一直遵守,但两个运行时 seam 都只看 environmentId、完全不看 organization_id:#4521 的写穿透会把 org 作用域的 overlay 直接写进 plain key,而 getMetaItems 的 hydration 循环遍历的是「env-wide + 本次调用方 org」的合并集合——只修写一侧,下一次列表调用就会把它还原回去,所以两个 seam 必须一起收口。

修法是把「行作用域」判决下沉到三个 hydration 调用方本就共享的唯一收口 hydrateOverlayIntoRegistry,并把 organizationId 设为必填参数:可选参数省略即等同 env-wide,正是这个洞本身;必填则第四个调用方无法在不作答的情况下通过编译。「内核作用域」判决(environmentId)仍留在调用方——它描述的是内核,不是这一行数据。

不改的部分:org 读者看到的一切(合并列表、getMetaItem 的 org 优先按需读、org 写入本身)、#4521 的 read-your-writes、以及 object 分支(物理表本就是 env-wide,assertObjectRegistered fail-closed,拦截会让 runtime 创建的对象数据 CRUD 不可达)。

删除侧按 issue 绑定点 1 对账:保留 plain-key 模型 + 两个入口都拒绝 org 行 ⇒ delete 链无需 re-keying,已双向钉住。同时诚实修正卡片的一处预设:「没有 org 作用域的东西可被误删」对条目成立,但 org 作用域的删除操作仍会命中 env-wide 条目(restoreArtifactRegistryView 是 org-blind 的)——已实测,且属本 PR 之前就存在、与本改动无关的问题,另行开 issue,不夹带。

新增 18 条 pin;反向验证事先预测 11 red / 7 green,实测 11 red / 7 green 且红的正是预测集合。


Generated by Claude Code

claude added 2 commits August 8, 2026 15:22
…he process-wide SchemaRegistry (#6602)

Both runtime hydration seams gated on `environmentId` alone and said
nothing about `organization_id`, so on an unscoped (control-plane)
kernel a per-org overlay reached the shared registry under the plain
key — through the #4521 write-through, and again through the
`getMetaItems` read hydration one listing call later.

The row-scope verdict now lives in `hydrateOverlayIntoRegistry`, the one
choke point all three hydration callers already share, with a REQUIRED
`organizationId` argument so a fourth caller cannot forget it. The
kernel-scope gate stays with the callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 5:16pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 8, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 8, 2026 17:30
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 4bb6f01 Aug 8, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6602-org-overlay-registry-gate branch August 8, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants