fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908
fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908os-musk wants to merge 1 commit into
getMetaItem's overlay read on the metadata registry#14908Conversation
…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 b04ec93b582475a62471eb4b04b23c9ad07ea8ec && git checkout b04ec93b582475a62471eb4b04b23c9ad07ea8ec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 23619f579f37aa03fee76a8554ace54a42bd9748 23a2b51c2a392ef7f05ac9787dfba7f143902202 && git checkout -B drift-repro 23619f579f37aa03fee76a8554ace54a42bd9748 && git merge --no-ff 23a2b51c2a392ef7f05ac9787dfba7f143902202
node scripts/docs-audit/affected-docs.mjs --json 23619f579f37aa03fee76a8554ace54a42bd9748
|
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.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. The served document then vanishes at the next restart, because boot hydration does not hydrate that row.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 directly, so no judgement call was needed:RUNTIME READ getMetaItem(type, name)→1. sys_metadata WHERE (type, name, …, state='active')marked← overlay (wins), then2. SchemaRegistry / MetadataService ← artifact default. Overlay wins; it does not merge.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 ADR-0005 itself.objectoverlay the same way:base = overlay ?? own.organizationIdForMetaRead's own docblock quotes this expression 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: an org-less read resolving only the env-wide row is what that card calls the bug. The defect was never the combinator; it was which roworgIdselects.⇒ The union question is answered against a union, with citations, 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.⇒ Nothing double-scopes and nothing is wrongly denied. What moves is the three ungated callers in
packages/runtime/src/domains/meta.ts(:703,:745,:768), two of them hard-coded to typeobject, which isallowOrgOverride: false— callers that cannot be right about scope by construction. They 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.Ablation (fix committed first, then mutated, then restored):
grep -c; blob58769b0dddiffers fromHEADblob3111ed266expected 'org_acme showcase_task' to be 'env showcase_task'— the phantom served, the defect reproduced) and §5's draft armgit diff HEADempty,git statusclean, working blob3111ed266byte-identical to theHEADblobNo rebuild leg was needed: the test imports the subject relatively (
./protocol.js) from within its own package, so vitest readssrc/protocol.tsdirectly rather than adist/artifact.Verification, at
23a2b51c2(the final commit)pnpm --filter @objectstack/metadata-protocol testpnpm --filter @objectstack/metadata-protocol typechecktsc --noEmit --listFilesshows the new test file is in the program (1 hit), so this covers itpnpm --filter '@objectstack/metadata-protocol^...' buildnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackcheck-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 finding.eslint . --no-inline-config(full repo, not narrowed)node scripts/pm/check-governed-merges.mjs --teston the final file listcheck:nul-bytesplus a manual control-byte scan of all three filesExit codes were captured by redirecting to a file before reading it, never through a pipe.
Scope
Three files.
packages/specis untouched — 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:345. 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.Changeset
patch, on@objectstack/metadata-protocol— a behaviour correction on an existing published read door.node scripts/check-changeset-no-major.mjsandnode scripts/check-empty-changeset.mjsboth exit 0. The body states what is unchanged for already-gating and organization-less callers alongside what moves, since that is the part an upgrading consumer needs.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
Generated by Claude Code