Skip to content

fix(rest): serve org-scoped metadata back through every /meta read door - #9727

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-9454-org-scoped-meta-read-door
Aug 18, 2026
Merged

fix(rest): serve org-scoped metadata back through every /meta read door#9727
os-zhuang merged 4 commits into
mainfrom
claude/issue-9454-org-scoped-meta-read-door

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#9454

A runtime PUT of an org-overridable metadata type — view, dashboard, report, translation, email_template — answered 200 with a receipt reporting state: 'active', persisted the row with its organization_id, and was then served back by nothing: direct GET 404, 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

  • The write door is correct as-is. The row really is persisted, so the 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.
  • The overlay-resolution layer is correct too, and type-agnostic. getMetaItem resolves (orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null); getMetaItems unions both scopes under org-wins precedence; getMetaItemLayered even reports overlayScope. (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.)
  • The REST read doors never stated the scope. Every one asked for the env-wide partition, so the org partition was never consulted.

Deliverable — exhaustive enumeration of every meta read call site in rest-server.ts

The 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.

#verbtype argumentclassreason
1getMetaItems'object' (literal)outloadObjectItems, api-exposure gate. object is allowOrgOverride: false
2getMetaItems'book' (literal)outfetchAudienceBooks. book is not org-overridable
3getMetaItemLayeredreq.params.typeINserveMetaItemLayered — the Studio layered/diagnostic view
4getMetaItems'object' (literal)outOpenAPI {object} path expansion. Not org-overridable
5getMetaItems'api' (literal)outOpenAPI endpoint enrichment. api is not org-overridable
6getMetaItemsreq.params.typeINthe LIST door — the card's "scoped listing unchanged"
7getMetaItems'book' (literal)outbook-tree route. Not org-overridable
8getMetaItems'doc' (literal)outbook-tree docs. Not org-overridable
9getMetaItemCachedreq.params.typeINthe cached armview's route
10getMetaItemreq.params.typeINthe uncached armdashboard's route via isDashboardType
11getMetaItems'doc' (literal)outaudience corpus for doc gating. Not org-overridable
12getMetaItemLayereddynamicalready correct/published overlay read — already threads organizationId from publishedCtx?.tenantId. ⛔ Not touched
13getMetaItemreq.params.typeINthe compound-name door (/:type/:section/:name)
14getMetaItem'object' (literal)outexport field metadata. Not org-overridable
15getMetaItems'view' (literal)out, by callerresolveFormBySlug, the public/forms/:slug route. viewis org-overridable, so this is not out-of-scope by type — see below
16getMetaItems'object' (literal)outpublic-form object schema narrowing. Not org-overridable
17getMetaItems'object' (literal)outpublic-form lookup referenceTo resolution. Not org-overridable
18getMetaItems'dataset' (literal)outanalytics dataset lookup. Not org-overridable

⇒ 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/:slug is deliberately anonymous — it bypasses enforceAuth and resolves no execution context anywhere in its region, delegating security to the guest_portal permission set. There is therefore no session-derived active org to state: organizationIdForMetaRead('view', undefined) answers undefined at 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-coreorganizationIdForMetaRead joins organizationIdForMetaWrite, deriving from the same allowOrgOverride registry flag, so read scope and write scope cannot drift.
  • packages/metadata-protocolgetMetaItemCached gains organizationId. It was the only meta read verb that could not express one, having hard-coded a two-key delegation to getMetaItem.
  • packages/rest — the scope is threaded at the five in-scope doors through the already-memoisedresolveExecCtx (WeakMap keyed by req plus environmentId), so no new per-request org resolution is introduced.

Not a bare ctx?.tenantId per 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 stamp organization_id on 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: matchesWhere skips undefined keys, so it matches an arbitrary tenant's row.

Both branches, by construction

view takes the cached arm; dashboard bypasses it via isDashboardType. 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

getMetaItemCached now folds organizationId into its hash. The mechanism differs from locale: locale is 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 is private, no-cache and 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.

  • Predicted: 11 red / 4 green.
  • Measured: 11 red / 4 green, membership as predicted. Failure shape names the bug: GET /view did not serve the item: expected 404 to be 200.

metadata-core and metadata-protocol are not vitest-aliased in packages/rest, so they resolve to dist/ — the ablation was rebuilt and its reach into the artifact proven both ways with scripts/ablation-dist-preflight.mjs (--absent on the ablation leg, present on restore). Restored byte-identically from the commit; 15/15 green again.

gateresult
check:authz-resolverOK — single shared authorization resolver intact
check:cross-package-test-inputsOK — 12 packages read outside themselves, all declared
check:dispatcher-error-vocabularyOK — 17 unregistered code-stamping sites, all classified
check:durability-log-levelOK — 29 seams, all loud
check:filter-alias-parityOK — 4 wire spellings identical both sides
check:route-envelopeOK
check:query-options-erasureOK — baseline verified, no files added
check:engine-double-contractOK — 321 pinned, 133 in ledger, 2 exempt
check:where-matcherOK — 0 silently-wrong, none new
check:nul-bytesOK — 6217 files, no raw control bytes
check:type-check-coverageOK — 64/77 packages type-checked
check:type-check-debt (ratchet)OK — 33 ledger entries re-measured in 334.4s, 1926 raw errors, none above its recorded number
check-affected-docsOK — 242 self-test cases
typecheck (rest, metadata-core)Done, exit 0
rest suite127 files, 2077 passed
metadata-protocol suite122 files, 1684 passed, 10 skipped
metadata-core suite10 files, 162 passed

⚠️@objectstack/metadata-protocol carries notypecheck script, so no typecheck is claimed for it — a pnpm --filter run there is a zero-match exit-0 false green.

All of the above was measured on 54b446818 — the head of this branch, after main was merged in (12 commits, no conflicts, none touching this surface). The gate union was re-derived from the actual changed paths with node scripts/pm/dispatch-gates.mjs, not taken from the dispatch brief, and re-run on that head.

Out of scope, filed not fixed


Generated by Claude Code

zhuangjianguoand others added 4 commits August 18, 2026 16:19
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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/rest, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via ObjectStackProtocolImplementation (symbol))
  • content/docs/kernel/services-checklist.mdx(via getMetaItemCached (symbol))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via ObjectStackProtocolImplementation (symbol))
  • content/docs/releases/v17.mdx(via ObjectStackProtocolImplementation (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9bfd54105853bef3af39685502454bb8c7ce3befpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d3918c06b10d43c0ee438df9deddcc539e28912 — the merge of head 54b446818747ef8ed254a445fe8ea0aa0a794c3b into base 9bfd54105853bef3af39685502454bb8c7ce3bef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9bfd54105853bef3af39685502454bb8c7ce3bef → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 18, 2026 17:45
@os-zhuang
os-zhuang added this pull request to the merge queueAug 18, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — accepted, ready + auto-merge armed. ⭐ And it falsified my correction, which was itself a correction.

domain:cli seat, session session_012WKSnqAaoqtW3QX7SSf1Vk. Dev returned status: done, no open questions. All six required checks completed: success at head 54b446818.

⛔ My error, stated first, because it is the sharpest thing in this round

I 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 origin/main:

:1303 (p as any).getMetaItems?.({ …
:3257 protocol?.getMetaItems?.({ type: 'object' })
:3298 protocol?.getMetaItems?.({ type: 'api' })
:8347 (p as any).getMetaItems?.({ type: 'dataset', … })

My scan shape was .getMetaItems(; the optional-call form .getMetaItems?.( is invisible to it. 17 by my query, 21 counting both forms, and the real call-site population is 18 once non-call matches are excluded.

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.resolveFormBySlug, backing the public /forms/:slug route, reads a hardcoded type: 'view' — an org-overridable type, so it is the single site not excluded by its type. It is excluded by caller: the route is deliberately anonymous, bypasses enforceAuth, and resolves no execution context anywhere in its region, so organizationIdForMetaRead('view', undefined) answers undefined at every call and wiring it would only add a session resolution to a public route. Filed as #9725 rather than silently skipped.

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 repetition

The card's central hazard was that view takes the cached arm and dashboard bypasses it via isDashboardType, so fixing one fixes exactly one type. The dev did not thread both arms separately — it hoisted a single resolution above the fork, so the two arms are structurally incapable of disagreeing. That removes the failure mode rather than covering it twice.

ETag — option B chosen, and the precedent correctly refused as a precedent

I offered the #1319locale analogy. The dev took option B but rejected my reasoning for it:

the #1319 locale precedent is analogous in shape but not in mechanism — locale is invisible to the hash (the body is translated after the validator runs), which is why folding it in was the only way it could vary the validator; organizationId is visible, because the org-resolved document is the very thing hashed.

⇒ Option A was functionally correct on today's code, and it explicitly declines to claim a measured leak (private, no-cache, no server-side entry keyed by type+name). B was chosen so scope is a declared property of the validator rather than an emergent one — two orgs whose documents are byte-identical today share a validator by coincidence. Correct call, correctly reasoned, and correctly refusing to borrow authority from a precedent that does not transfer.

⚠️ The ablation would have been a false green, and it knew why

metadata-core and metadata-protocol are not vitest-aliased in packages/rest (only plugin-hono-server and service-datasource are), so they resolve through the workspace link to dist/. A source-only ablation would have passed while proving nothing. Both legs were rebuilt and proven into the artifact with scripts/ablation-dist-preflight.mjs--absent: "marker absent from all 18 built files"; restore: "marker present in 4 built files". Predicted 11 red / 4 green before running; measured 11/4 with membership exactly as predicted, the four green being absence-asserting controls that hold trivially pre-fix.

Also correct

  • ⚠️ An earlier check:type-check-debt ELIFECYCLE was diagnosed as its own timeout 400 cutting a 334s run short — not a drift. Re-run with a longer guard, passed. Reporting that as a gate failure would have been wrong; hiding it would also have been wrong.
  • @objectstack/metadata-protocol carries notypecheck script — confirmed by reading package.json, and no typecheck is claimed for it. That is the zero-match exit-0 false green, refused by name.
  • [finding] The spec's three meta-read request schemas omit organizationId, which the protocol implementation accepts and honours #9726 filed: the spec's three meta-read request schemas omit organizationId while the implementation has accepted and honoured it all along — pre-existing declared-vs-implemented drift, packages/spec belongs to another seat, ⛔ not widened into.
  • Zone 2 resolveExecCtx memoisation confirmed by reading it: execCtxMemo, a WeakMap keyed on req caching the in-flight promise. Threading five doors adds no new per-request resolution.
  • Trailer conflict flagged rather than silently resolved; my instruction followed, and no force-push was needed because the branch was still unpushed when it arrived.

#9725 and #9726 are unassigned for triage. ⛔ Not graded by this seat.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door

3 participants

@os-zhuang@zhuangjianguo@claude