fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908
Conversation
…data registry The singular `/meta` read verb applied no organization gate of its own: whatever `organizationId` arrived was spent on whatever `type` arrived. Its two overlay reads combine with `??` — precedence, not the plural verb's union — so an ungated organization could SUBSTITUTE rather than merely add: on a type the registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row was served instead of the live env-wide document. Resolve the scope through `organizationIdForMetaRead` once, after the canonical type fold, for both the active-overlay read and the ADR-0033 `previewDrafts` read. ADR-0005's overlay-wins precedence is deliberately unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9
|
…verridable type `getMetaItem` now resolves its own read scope through `organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since #6483 — the org partition is never queried. The two `per-organization overlay isolation` fixtures that exercised the singular verb on `app` were therefore measuring the phantom read this branch removes: one went red, and its sibling stayed green while asserting a fall-through that could no longer fail. Both move to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683 already made in the same block, and each gains the call assertion that makes its claim non-vacuous. Prose corrections in the same edit, from the contract review: - the changeset no longer says the plural gate shipped "in the previous release" — both changesets are unconsumed, so both land in the same one; - the phantom row is never deleted, so a restart does not clear the substitution; what a restart drops is the row's registry presence; - the "what moves" enumeration names four raw-org callers across two files, with the method that establishes the population; - the write-side read/write-scope identity is qualified under the `OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations are narrowed to what those documents actually decide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…tmetaitem-overlay-precedence
Contract review (re-review after the fix round): PASS — head
|
Fixes #14770
getMetaItem— the singular/metaread verb — now resolves its own read scope throughorganizationIdForMetaRead, the registry-derived predicate the REST/metadoors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.Landed as
a2ff587d4, then merged withorigin/mainasc5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.protocol-meta.test.ts:89-112expected the org row onappview; the vacuous sibling at:114-129moved in the same edit and gained the assertion that makes it non-vacuous.Tests 95 passed (95), was1 failed | 94 passedsys_metadatarow answersruntime/src/domains/packages.ts:1239,applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the populationAdvisory, also taken: A1 the write-side read/write-scope identity is qualified under the
OS_METADATA_WRITABLEhatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("sixplugin-securitysites" — actually five) lives on #14907, not in this PR, and was corrected there.The defect, measured
At the merge base
84b8190ae,packages/metadata-protocol/src/protocol.ts:7348:spent by both overlay reads in the method, the second of which is at
:7452::7382-7383and then:7453; on the merge base it is:7348/:7452. Immaterial to the finding, recorded because the file keeps moving.??is precedence, not the plural verb's union. OngetMetaItemsthe twoqueryByOrgreads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declaresallowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kindloadMetaFromDbwalks past andreportUnhydratableOrgScopedRowswarns about — was served instead of the live env-wide document, to a caller that asked for the live one.loadMetaFromDbfiltersorganization_id: null(protocol.ts:20703-20705) and skips the rest;reportUnhydratableOrgScopedRowsonlyconsole.warns (:21053); the soleengine.delete('sys_metadata', …)(:20552) is insidedeleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while asys_metadatarow answers.The change
One line, plus the comment that carries the reasoning:
Three properties, each deliberate:
canonicalizeMetaRequestType(request)is already this method's first statement, and it appliescanonicalMetaType, which iscanonicalMetaUrlType. Gating after it is what makes the REST idempotence leg below hold, and it is the order/metaorg scope is decided from the RAW url spelling:translations/email_templatesread and write env-wide where their singular twin is org-scoped #10340 measured:declaresOrgOverridetolerates the manifest plurals but not the URL-only ones (translations/email_templateshave no manifest key), so a raw segment reaching the predicate splits one item across two partitions.orgIdwas already the single carrier for the ADR-0033previewDraftsread and the active-overlay read. Changing the initializer moves both. A gate threaded into only one arm would leave the draft preview serving exactly the phantom the active read had stopped serving — the half-measure shape Org-overridable metadata (view,dashboard) is accepted with a 200state:'active'receipt but served by no read door #9454's hoist comment refuses.??is untouched. See below.⛔ Why precedence stays — the question Zone 2 C asked
The card's title names the read as "
??PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:RUNTIME READ getMetaItem(type, name)→1. sys_metadata WHERE (type, name, …, state='active')marked← overlay (wins), then2. SchemaRegistry / MetadataService ← artifact default.??. An earlier draft of this section overstated that.MetadataOverlaySchemawas retired and deleted whole under ADR-0049 — ADR-0005 still namesmetadata-customization.zod.tsas a kept, future-phase model after #13135 retires it #13185, PR Retire the paper metadata-customization protocol with its full coupling set #13186, maintainer ruling of 2026-08-29 — and ADR-0126 §6 rules out the phase it was held for. Recorded as a correction inside principle 3 itself.organizationIdForMetaRead's own docblock quotes this expression —(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)— as the intended shape while defining Org-overridable metadata (view,dashboard) is accepted with a 200state:'active'receipt but served by no read door #9454. Precedence is the behaviour that card was written against, not the thing it reported; what Org-overridable metadata (view,dashboard) is accepted with a 200state:'active'receipt but served by no read door #9454 calls the bug is an org-less read resolving only the env-wide row.resolveObject"selects its base layer asoverlay ?? ownerinstead ofowner, then foldsextendcontributions". Its status line reads "Design only — nothing is implemented yet", so it is corroboration and not an authority this method rests on. An earlier draft rendered it asbase = overlay ?? ownand presented that paraphrase as the ADR's spelling.⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.
The idempotence proof, discharged before the gate was written
The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let
f(t, o) = organizationIdForMetaRead(t, o).f(t, undefined) === undefined— the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before:rest/src/import-mapping.ts:66,rest/src/import-prepare.ts:416,rest-server.ts:8634,plugin-email's template read,service-analytics' draft probe,plugin-auth'smetaReader. Test §3 sweeps this over the complete accepted-spelling population.f(t, f(t, o)) === f(t, o)—fanswersofor an overridable type andundefinedotherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId)atrest-server.ts:5660and then passestype: req.params.type, the raw segment; this method folds that segment with the same function, sorequest.typeat the gate is the identical string the door gated on. Its cached arm reaches here throughgetMetaItemCached, which folds first and forwards the same hoistedreadOrganizationId— the same no-op one hop later.organizationIdForMetaWritehas a character-identical body, so the three internal write-side pre-reads —saveMetaItem's destructive-change probe,publishMetaItem's seed-loader adapter,publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view,dashboard) is accepted with a 200state:'active'receipt but served by no read door #9454 chose this predicate for.orgScopedWriteRefusalreturns early whenisOverlayAllowedis satisfied viaOS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.⇒ Nothing double-scopes and nothing is wrongly denied.
⭐ What moves — four raw-org callers, with the method that establishes the population (B4)
An earlier draft asserted three and named one file. Corrected. Method:
git grep -nE 'getMetaItem(Cached)?\s*\('across the whole repo excluding tests,docs/,content/anddist/— 14 invocation sites — then eachorganizationIdargument traced to its source and partitioned into "alreadyorganizationIdForMetaRead" versus "raw".runtime/src/domains/meta.ts:703'object'allowOrgOverride: false— a caller that cannot be right about scope, by constructionruntime/src/domains/meta.ts:745'object'runtime/src/domains/meta.ts:768singularTypeoff the URLruntime/src/domains/packages.ts:1239(applyPublishedSeeds, org fromdeps.resolveActiveOrganizationIdat:472, forwarded at:492-494)'seed'attemptsat:1233-1235), so what it used to reach on the second try it now gets on the firstEvery other invocation either names no organization at all, or is a REST door that already computed
organizationIdForMetaRead— the idempotence legs above are what make those two cases no-ops.getMetaItemCachedhas exactly one non-test caller,rest-server.ts:5711, which is the gated cached arm.All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.
Tests
packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts— 14 cases, modelled on the plural door's pin.objectrow and an org-scoped phantom of the same(type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the??fallback chain when the org has no overlay of its own.META_URL_TO_SINGULAR∪ registry-singular population.previewDraftsarm gets the same scope, both directions.objectis not in it.packages/objectql/src/protocol-meta.test.ts— the two singular-verb fixtures inper-organization overlay isolationmove fromapptoview(B1).approlled back toallowOrgOverride: falsein #6483, so once this verb gates,appnever reaches the org partition at all. Onappthe first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run onview, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.The
[#14683]note that predicted"Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here"is marked superseded in place, with the date and what falsified it.Non-vacuity, measured rather than asserted (fix committed at
a2ff587d4first, then mutated, then restored):app(row, request and the new call assertion together); injectedapprequest formgrep -c→ 1, working blobd1f8da070differs from theHEADblobf612521dbTests 1 failed | 94 passed (95)— and the failure is the call assertion,AssertionError: expected "vi.fn()" to be called with arguments, not thelabelassertion, which still passed. That is the vacuity itself, printed: onappthe case cannot reach the org partition, so the fall-through it claims to measure is unmeasurablegit diff HEADempty,git statusclean, working blobf612521dbbyte-identical to theHEADblob@objectstack/metadata-protocolviaexports→dist/— was rebuilt at this commit before the runThe earlier ablation of the gate itself still stands from the first round: with
const orgId = request.organizationId;restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.Verification, at
c5c3f062e(the final commit,origin/mainmerged in)pnpm --filter @objectstack/objectql exec vitest run… vitest run src/protocol-meta.test.ts1 failed | 94 passedat23a2b51c2pnpm --filter @objectstack/metadata-protocol exec vitest run… vitest run src/get-meta-item-org-read-gate.test.tspnpm --filter @objectstack/metadata-protocol typechecktsc --noEmit --listFilesshows both the edited pin test andsrc/protocol.tsin the program (1 hit each), so this covers thempnpm --filter @objectstack/objectql typecheckcheck:test-typecheck—OK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmovedpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no path arguments)check-test-completeness.mjs,pm/check-half-states.mjsturbo run testlog, the second needs repo-scoped REST egress this container does not have. Neither is a findingcheck:dual-build-cjs-loads,check:type-check-debteslint . --no-inline-config(full repo, not narrowed)node scripts/pm/check-governed-merges.mjs --teston the final 4-file list0 of 4 path(s) hit the register— not governedcheck:nul-bytesplus a manual control-byte scan of all four filesgrep -naPexit 1, no matches)Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.
Scope
Four files.
packages/specis untouched — the review confirmed it needs no change — including theorganizationIddescribe()wording tracked separately on #14772, which is not addressed here and stays open.Filed while working this, out of scope for this PR: #14907 —
getMetaItemLayeredis the same series' third instalment, reached ungated fromruntime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and itsorgIdbinds before the canonical fold, so this one-liner does not port. Its body's "sixplugin-securitycall sites" was corrected to five — the actual non-testgetMetaItemLayered(invocations arepackaged-permission-set-lock-gate.ts:82andpermission-set-projection.ts:770,:938,:995,:1406;permission-set-overlay-discard.ts:243only feature-detects and delegates.Changeset
patch, on@objectstack/metadata-protocol— a behaviour correction on an existing published read door. Semver verified againstb6c769019,26f3588fb,67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round.node scripts/check-changeset-no-major.mjsandnode scripts/check-empty-changeset.mjsboth exit 0.Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767 landing in the same release (verified on
origin/main:.changeset/getmetaitems-org-read-gate.mdis still present and unconsumed,packages/metadata-protocol/package.jsonis17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.Review
needs:contract-reviewtravels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code