Skip to content

An org-scoped overlay row reaches the process-wide SchemaRegistry on unscoped kernels — both the write-through and the read hydration gate only on environmentId, never on org #6602

Description

@baozhoutao

Found while verifying #6190 against origin/main (impact ② of that card). Filed separately per Prime Directive #10 — it is not flow-specific and it survives whichever way #6190's contract question is ruled, so it does not belong in #6190's diff.

The fact (measured, not inferred)

applyRegistryWriteThrough (packages/metadata-protocol/src/protocol.ts) states its own invariant in its TSDoc:

The non-object branch carries the same environmentId === undefined gate the read-side hydration carries: 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.

The gate is one line — if (this.environmentId !== undefined) return; — and it says nothing about organization_id. So on an unscoped kernel (environmentId === undefined), a per-org overlay write hydrates straight into the process-wide SchemaRegistry under the plain key.

Measured with the real ObjectStackProtocolImplementation over a stub engine, tracking registry.registerItem calls:

PROBE P3 rows = [{"type":"view","name":"org_grid","org":"org_a"}]
PROBE P3 registry write-through = [{"type":"view","name":"org_grid"}]

view is legitimately allowOrgOverride: true, so this is not a case of a type that should not have been writable — it is the designed per-org overlay leaking out of its org. flow behaves identically on the runtime-create tier (PROBE P1 registry write-through = [{"type":"flow","name":"org_sweep"}, ...]), which is how #6190's own impact ② was first seen.

This matters because loadMetaFromDb's filter carries the opposite rule as a comment, in the same file:

// ADR-0005 (revised 2026-05): hydrate only env-wide rows
// (organization_id IS NULL). Per-org overlays are loaded on
// demand by getMetaItem to avoid cross-org leakage into the
// process-wide SchemaRegistry.

Boot is careful about exactly the thing the write-through is not.

The read side has the same hole — so the TSDoc's framing is now wrong in a second way

getMetaItems merges orgRecords into overlays and then hydrates:

if (this.environmentId === undefined) {
    for (const { data, packageId: recPkg } of overlays) {
        this.hydrateOverlayIntoRegistry(request.type, data, recPkg);
    }
}

overlays is built from envWideRecords plus orgRecords, so one org-scoped listing call (getMetaItems({ type, organizationId })) grafts that org's overlay bodies into the shared registry too. The write is therefore not "more permissive than the read" — both are equally permissive about org, and only the boot path holds the line. Whoever fixes this should fix both seams together, or the next listing call undoes the write-side fix.

Why it is observable, not theoretical

On an unscoped kernel getMetaItems starts from registry.listItems(type) and merges DB rows on top. Once org A's overlay body sits in the registry under the plain key, a listing for org B starts from org A's body — org B's own DB rows overwrite it only where the names collide, and where they do not, org A's item is simply in org B's list. Single-kernel is not a corner case here: per #5086's note in this same file, a host config with instantiated plugins boots new ObjectQLPlugin() with no environmentId, so the flagship showcase runs with environmentId === undefined.

Not a duplicate

Searched open issues/PRs across the three repos for applyRegistryWriteThrough, hydrateOverlayIntoRegistry, cross-org + SchemaRegistry, and the Chinese equivalents. Neighbours checked and distinct:

Refs

packages/metadata-protocol/src/protocol.ts — applyRegistryWriteThrough, hydrateOverlayIntoRegistry, the getMetaItems hydration loop, and loadMetaFromDb's filter comment; ADR-0005 §Tenant-customizable type whitelist; ADR-0010 §3.3; #4521 (the write-through's origin); #5086 (unscoped single kernels are a real end-user surface).

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Triage: pm:queue + domain:metadata + target:v17.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    第三个面,顺手记在这里而不是另开重复卡 —— 来自 #5079 的实测(PR #6687)。

    #6602 已经点名了写入侧两个 seam(applyRegistryWriteThrough 与 getMetaItems 的 org-hydration 循环)只按 environmentId 设门、不按 org。删除侧也一样对 org 无感:deleteMetaItem → restoreArtifactRegistryView → SchemaRegistry.removeRuntimeShadow(type, name) 完全按 (type, name) 寻址,从不看 organization_id。所以一次 org-scoped DELETE 会把那个共享平键条目摘掉,不问它是哪个 org 写进去的 —— 与写入侧同一个 org 盲区,只是方向相反(写入是泄漏,删除是误伤)。这不是 #5079 引入的:removeRuntimeShadow 一直如此。

    两条与 #6602 的具体交互,供修复者定范围时对账:

    1. An org-scoped overlay row reaches the process-wide SchemaRegistry on unscoped kernels — both the write-through and the read hydration gate only on environmentId, never on org #6602 的驻留期在 fix(objectql,metadata-protocol): a deleted runtime-created overlay leaves the registry, so list/get/dispatch agree (#5079) #6687 之后会变短,但泄漏本身没关。 目前那个平键条目一旦写进去就永久驻留(没有任何路径能移除一个 runtime 新建项的平键条目 —— 这正是 meta overlays: a deleted overlay stays in the /meta listing though dispatch correctly 404s (#4432 residual) #5079 的 bug);fix(objectql,metadata-protocol): a deleted runtime-created overlay leaves the registry, so list/get/dispatch agree (#5079) #6687 给了 SchemaRegistry.removeOverlayEntry,并让 restoreArtifactRegistryView 在「复合键 artifact 与 MetadataService baseline 都为空」时退役它。于是 org A 泄漏出去的条目至少能被一次 DELETE 清掉,窗口从「进程生命周期」缩到「到下一次删除」。一次没有对应删除的写入照样泄漏,所以 An org-scoped overlay row reaches the process-wide SchemaRegistry on unscoped kernels — both the write-through and the read hydration gate only on environmentId, never on org #6602 必须照原计划修。

    2. 修 An org-scoped overlay row reaches the process-wide SchemaRegistry on unscoped kernels — both the write-through and the read hydration gate only on environmentId, never on org #6602 时请把删除侧一起纳入。 如果写入侧改成按 org 分键(或拒绝把 org-scoped 行写进进程级 registry),那么 removeRuntimeShadow / removeOverlayEntry / restoreArtifactRegistryView 这条链的寻址方式也要跟着改,否则新旧两种键会在同一张 Map 里共存、而删除只认其中一种。An org-scoped overlay row reaches the process-wide SchemaRegistry on unscoped kernels — both the write-through and the read hydration gate only on environmentId, never on org #6602 正文里那句「Whoever fixes this should fix both seams together」在这里成立第三次。

    #6687 没有触碰 applyRegistryWriteThrough,也没有触碰 getMetaItems 的 hydration 循环 —— 它只改 restoreArtifactRegistryView 加一个 registry 原语,不吸收、不代修本卡。


    Generated by Claude Code

  3. self-assigned this
    on Aug 8, 2026
  4. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 2 (domain:metadata seat, sticker #6367)
    Session: session_01W6bLax4KMrSfnE1ydFU8Dw
    Branch: claude/issue-6602-org-overlay-registry-gate
    Worktree: objectstack-issue-6602
    Domain: domain:metadata
    File surface: packages/metadata-protocol/src/protocol.ts (applyRegistryWriteThrough ~:7582, the getMetaItems org-hydration loop, hydrateOverlayIntoRegistry as needed) + tests + one .changeset/*.md
    Serial constraints cleared: base must include PR #6687 (its restoreArtifactRegistryView diff at ~:7615 is adjacent to this card's write-through region; #6687 is in the merge queue — verify git log shows it before implementing). #6562 (read-exit injection) targets the same getMetaItems function and is deliberately held until this card lands (same-function serialization). #5079's delete-side third-face note on this thread is in scope to ACCOUNT FOR (verify interaction), not necessarily to fix — scope per the invariant. Round-2 siblings #6620/#6418 are disjoint regions.


    Generated by Claude Code

  5. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Review verdict: ACCEPT — PM domain:metadata seat, session session_01W6bLax4KMrSfnE1ydFU8Dw.

    PR #6779 restores the stated invariant at the choke point: hydrateOverlayIntoRegistry now takes a required organizationId and refuses org-scoped rows into the process-wide registry — an optional parameter would default to "env-wide", which was the hole itself; required means a fourth caller must answer to compile. Both seams (write-through with its three call sites, and the getMetaItems per-row loop) move together; loadMetaFromDb states its scope explicitly instead of leaning on a WHERE clause; the kernel-scope gate stays with the callers (activation surface untouched); the object branch's env-wide/fail-closed exception is reasoned in code. Delete side reconciled exactly per the claim's binding point 1 — no re-keying, pinned in both directions. Reverse verification predicted 11 red / 7 green and measured exactly that set; the one case that contradicted the dev's own presumption was rewritten as measured fact. Both required CI jobs read at completed/success by conclusion.

    Out-of-scope discipline: #6780 (org-scoped DELETE evicts the env-wide registry entry — pre-existing, measured on-branch, outside this card's ruled surface) filed for triage; the org-scoped object cold-boot specimen attached to #6190 rather than twinned. Queueing now; #6621 + #6562 dispatch on this commit's landing.


    Generated by Claude Code

  6. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Landing confirmed — PR #6779 merged as 4bb6f01af. Org-scoped overlay rows no longer reach the process-wide SchemaRegistry from either seam; hydrateOverlayIntoRegistry's required organizationId makes the next caller answer at compile time. This clears the target:v17 entry. #6780 and the #6190 specimen await triage. #6621 / #6562 dispatch now on this base. — domain:metadata PM, session session_01W6bLax4KMrSfnE1ydFU8Dw


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions