fix(metadata-protocol): an org-scoped overlay row no longer reaches the process-wide SchemaRegistry (#6602) - #6779
Merged
Conversation
…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
…-overlay-registry-gate
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 8, 2026
os-zhuang
marked this pull request as ready for review
August 8, 2026 17:30
This was referenced Aug 8, 2026
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-wideSchemaRegistry; 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:
loadMetaFromDbfiltersorganization_id: nulland 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 aboutorganization_id.Both runtime seams were therefore org-blind:
viewwrite hydrated straight into the registry under the plain key.viewis legitimatelyallowOrgOverride: true, so this is the designed per-org overlay leaking out of its org;flowreaches the same seam through the runtime-create tier.getMetaItemsmerges this caller'sorgRecordsintooverlaysand then hydrated the whole merged set under the sameenvironmentId === undefinedgate. 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/mainbefore 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
hydrateOverlayIntoRegistryis 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:organizationIdis 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:
applyRegistryWriteThroughtakesorganizationIdand passes it down. All three call sites hand it the sameorgIdthe row was written with —saveMetaItem(request.organizationId ?? null),runPublishSideEffects(args.orgId),rollbackMetaItem(request.organizationId ?? null) — so the registry's view cannot disagree with the row's scope.getMetaItemshydration loop carries each row's ownorganization_idalongside itspackage_idand judges per row. The merged listing is untouched: org readers still get their overlays.loadMetaFromDbpassesrecord.organization_idtoo. That is a no-op today by construction, and that is the point — the rule its comment states stops depending on aWHEREclause staying correct.Deliberately NOT changed
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.objectbranch. AnobjectisallowOrgOverride: falseand its physical table is env-wide, so the registry entry backing it is env-wide too.assertObjectRegisteredfails 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 theenvironmentIdgate 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/removeOverlayEntryneed 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: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 inobjectqlbecause that is where the protocol-plus-real-SchemaRegistryseam 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_REGISTRYrather 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
organizationIdrefusal 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 twogetMetaItemcases (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
organizationIdrequired, "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
Every gate step enumerated from
.github/workflows/lint.ymlwas 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
TS2559from 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
getMetaItemsfunction): UNAFFECTED, marginally easier. The two changes touch disjoint parts of the function — meta: an overlay-backed object read OMITS the injected system columns a registry-backed read includes — the same endpoint answers two different field sets #6562 works at the read EXIT (thedecorateMetadataItemsmapping that builds the response), this one inside the registry-hydration loop, which meta: an overlay-backed object read OMITS the injected system columns a registry-backed read includes — the same endpoint answers two different field sets #6562 does not read from or write to. The one incidental help: each overlay entry now carries its row'sorganizationIdnext topackageId, so if meta: an overlay-backed object read OMITS the injected system columns a registry-backed read includes — the same endpoint answers two different field sets #6562 ever needs the row's org at the exit it is already threaded through the same structure instead of needing a re-read.revertCommitnever refreshes the SchemaRegistry — the runtime keeps dispatching the reverted-away body until restart (the #4521 rule, missing on the batch path) #6621 (revertCommit registry refresh): EASIER, and the shape is now decided for it.revertCommitwill need to refresh the registry after restoring bodies, and the "how" question — how does a refresh avoid re-leaking an org-scoped body — is answered before it is asked: call the sameapplyRegistryWriteThroughwith the revert'sorgId, and the required parameter makes forgetting it a compile error rather than a silent leak to be re-discovered. Note for that author:rollbackMetaItem's call site in this PR is the exact template.中文摘要
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,assertObjectRegisteredfail-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