Uh oh!
There was an error while loading. Please reload this page.
fix(rest): serve org-scoped metadata back through every /meta read door - #9727
Conversation
A runtime PUT of an org-overridable metadata type (view, dashboard, report, translation, email_template) persisted an org-scoped sys_metadata row and answered 200 with a state:'active' receipt — and then no REST read door served it back, because none of them stated the organization scope. getMetaItem resolves (orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null), so an org-less read looks only in the env-wide partition. The author's work was persisted, receipted as live, and rendered as lost. - metadata-core: organizationIdForMetaRead, the read-side twin of organizationIdForMetaWrite, deriving from the same registry flag so read scope and write scope cannot drift. - metadata-protocol: getMetaItemCached gains organizationId (it was the sole meta read verb that could not express one) and folds it into the ETag. - rest: thread the scope at the five in-scope read doors, with one resolution hoisted above the cached / isDashboardType fork so both arms agree. WIP: pin harness bodies still being fixed to satisfy spec validation. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk
Spec-valid bodies per type (measured against the real validator), and the registry double gains isPackageDisabled so the LIST door does not 500. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk
…-scoped-meta-read-door
📓 Docs Drift CheckThis PR changes 3 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not seeCoarse fallback — 19 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 0d3918c06b10d43c0ee438df9deddcc539e28912 && git checkout 0d3918c06b10d43c0ee438df9deddcc539e28912
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9bfd54105853bef3af39685502454bb8c7ce3bef 54b446818747ef8ed254a445fe8ea0aa0a794c3b && git checkout -B drift-repro 9bfd54105853bef3af39685502454bb8c7ce3bef && git merge --no-ff 54b446818747ef8ed254a445fe8ea0aa0a794c3b
node scripts/docs-audit/affected-docs.mjs --json 9bfd54105853bef3af39685502454bb8c7ce3bef
|
os-zhuang
commented
Aug 18, 2026
PM review — accepted, ready + auto-merge armed. ⭐ And it falsified my correction, which was itself a correction.
⛔ My error, stated first, because it is the sharpest thing in this roundI posted a pre-dispatch correction warning that the card's inherited "5 read sites" was stale and must not be dispatched against, and supplied 14 from my own re-derivation. My 14 was short by 4, in exactly the way the 5 was short. Verified on My scan shape was All four are out of scope (hardcoded non-org-overridable literals), so the fix is unaffected — but that is luck, not method. Had one carried a dynamic type, I would have blessed an incomplete fix while believing I had corrected the card. ⇒ A correction is a reading too, and mine needed the same control I demanded of the card. This is precisely why the enumeration was made a Zone 1 deliverable rather than a detail, and it paid for itself on the seat that wrote it. The enumeration: 18 sites — 5 in scope, 1 already correct, 12 out⭐ Site 15 is the one worth reading. That is the difference between an enumeration and a list: every exclusion carries its reason, and the one exclusion that could have been a bug became an issue instead of a gap. The two-branch trap, answered structurally rather than by repetitionThe card's central hazard was that ETag — option B chosen, and the precedent correctly refused as a precedentI offered the #1319
⇒ Option A was functionally correct on today's code, and it explicitly declines to claim a measured leak (
|
Fixes#9454
A runtime
PUTof an org-overridable metadata type —view,dashboard,report,translation,email_template— answered 200 with a receipt reportingstate: 'active', persisted the row with itsorganization_id, and was then served back by nothing: directGET404, scoped listing unchanged, unfiltered listing missing it, browser blank or "Dashboard Not Found". The platform reported success in the same breath as not delivering the author's work.The defect was in the caller, not in either layer the card suspected
200 state:'active'receipt is truthful. This is persisted-but-not-served, never a silent write no-op — so the accept set does not change and this is a bug repair, not a contract change.getMetaItemresolves(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null);getMetaItemsunions both scopes under org-wins precedence;getMetaItemLayeredeven reportsoverlayScope. (The card body's "What a Fix Has to Settle" section names the overlay-resolution layer as the fix site — its conclusion stands, its location does not.)Deliverable — exhaustive enumeration of every meta read call site in
rest-server.tsThe inherited count of "5 read sites" is stale, and so is the broader re-derivation of 14 — the four optional-call (
?.()) forms are invisible to a plain.getMetaItems(scan. The real population is 18.getMetaItems'object'(literal)loadObjectItems, api-exposure gate.objectisallowOrgOverride: falsegetMetaItems'book'(literal)fetchAudienceBooks.bookis not org-overridablegetMetaItemLayeredreq.params.typeserveMetaItemLayered— the Studio layered/diagnostic viewgetMetaItems'object'(literal){object}path expansion. Not org-overridablegetMetaItems'api'(literal)apiis not org-overridablegetMetaItemsreq.params.typegetMetaItems'book'(literal)getMetaItems'doc'(literal)getMetaItemCachedreq.params.typeview's routegetMetaItemreq.params.typedashboard's route viaisDashboardTypegetMetaItems'doc'(literal)getMetaItemLayered/publishedoverlay read — already threadsorganizationIdfrompublishedCtx?.tenantId. ⛔ Not touchedgetMetaItemreq.params.type/:type/:section/:name)getMetaItem'object'(literal)getMetaItems'view'(literal)resolveFormBySlug, the public/forms/:slugroute.viewis org-overridable, so this is not out-of-scope by type — see belowgetMetaItems'object'(literal)getMetaItems'object'(literal)referenceToresolution. Not org-overridablegetMetaItems'dataset'(literal)⇒ 5 in scope · 1 already correct · 12 out of scope.
Site 15 is the one that deserves its reason spelled out rather than a silent skip, because it is the only out-of-scope site that names an org-overridable type.
/forms/:slugis deliberately anonymous — it bypassesenforceAuthand resolves no execution context anywhere in its region, delegating security to theguest_portalpermission set. There is therefore no session-derived active org to state:organizationIdForMetaRead('view', undefined)answersundefinedat every call, so wiring it would add a session resolution to a public route and change nothing. Serving an org overlay to anonymous visitors would require deriving the org from the host/environment rather than the session — a different mechanism, and a new seam. Filed separately rather than smuggled in here.The shape
One registry-derived predicate, threaded at the classified read doors.
packages/metadata-core—organizationIdForMetaReadjoinsorganizationIdForMetaWrite, deriving from the sameallowOrgOverrideregistry flag, so read scope and write scope cannot drift.packages/metadata-protocol—getMetaItemCachedgainsorganizationId. It was the only meta read verb that could not express one, having hard-coded a two-key delegation togetMetaItem.packages/rest— the scope is threaded at the five in-scope doors through the already-memoisedresolveExecCtx(WeakMap keyed byreqplusenvironmentId), so no new per-request org resolution is introduced.⛔ Not a bare
ctx?.tenantIdper site, and the reason is measurable rather than stylistic: deployments predating the #6190 ruling hold phantom org-scoped rows for non-overridable types (the runtime used to stamporganization_idon every type). Boot hydration deliberately walks past them. A read door naming the org for every type would resolve them again — serving a document that vanishes at the next restart.⛔ Not the org-blind overlay fallback. Matching any org row when the caller names none is a cross-tenant disclosure:
matchesWhereskipsundefinedkeys, so it matches an arbitrary tenant's row.Both branches, by construction
viewtakes the cached arm;dashboardbypasses it viaisDashboardType. Both omitted the org, so a fix to one arm would fix exactly one type while the receipt kept claiming success for the other. The org is now resolved above the fork, so the two arms cannot disagree.ETag — folded in, deliberately, and without overclaiming
getMetaItemCachednow foldsorganizationIdinto its hash. The mechanism differs fromlocale:localeis invisible to the hash (the body is translated after the validator runs), which is why #1319 had to fold it; the org-resolved document is the thing hashed. No leak is claimed — the directive isprivate, no-cacheand there is no server-side cache entry keyed by type+name. It is folded in because it makes scope a declared property of the validator rather than an emergent property of the body. Prepended only when present, so an org-less caller's validator is byte-identical to today's.Verification
Reverse verification, direction predicted in writing before running: ablate the three source files, keep the pin.
GET /view did not serve the item: expected 404 to be 200.metadata-coreandmetadata-protocolare not vitest-aliased inpackages/rest, so they resolve todist/— the ablation was rebuilt and its reach into the artifact proven both ways withscripts/ablation-dist-preflight.mjs(--absenton the ablation leg, present on restore). Restored byte-identically from the commit; 15/15 green again.check:authz-resolvercheck:cross-package-test-inputscheck:dispatcher-error-vocabularycheck:durability-log-levelcheck:filter-alias-paritycheck:route-envelopecheck:query-options-erasurecheck:engine-double-contractcheck:where-matchercheck:nul-bytescheck:type-check-coveragecheck:type-check-debt(ratchet)check-affected-docstypecheck(rest,metadata-core)restsuitemetadata-protocolsuitemetadata-coresuite@objectstack/metadata-protocolcarries notypecheckscript, so no typecheck is claimed for it — apnpm --filterrun there is a zero-match exit-0 false green.All of the above was measured on
54b446818— the head of this branch, aftermainwas merged in (12 commits, no conflicts, none touching this surface). The gate union was re-derived from the actual changed paths withnode scripts/pm/dispatch-gates.mjs, not taken from the dispatch brief, and re-run on that head.Out of scope, filed not fixed
/forms/:slugcannot serve an org overlay of itsview— the route is anonymous by construction, so there is no session-derived org #9725 — public/forms/:slugreads a hardcodedtype: 'view'but is deliberately anonymous, so it has no session-derived org to state. The only out-of-scope site naming an org-overridable type; classified with its reason rather than silently skipped.organizationId, which the protocol implementation accepts and honours #9726 —[finding]the spec's three meta-read request schemas omitorganizationIdwhile the implementation accepts and honours it. Pre-existing drift,packages/specbelongs to another seat.Generated by Claude Code