fix(rest, runtime): the dispatcher's /meta doors scope to the vetted organization, and its item read, book tree and list answer what RestServer answers (#20408) - #20473
Conversation
…s (red on base) The census cells the dispatcher answers differently from RestServer on origin/main b285508: the org source of the /meta doors, the item read's translation, doc locale, sortability, Vary and draft switches, the list's unknown-type refusal and ?preview= casing, the book-tree route, and the undetermined-posture cache header. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ree, the unknown-type refusal, the mask's cache posture and the caller's organization RestServer's list, item and book-tree handlers now hand their answers to the shared functions, step for step. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…item chain, book tree, refusal, mask posture and vetted organization Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…he execution context Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…spatcher-meta-item-parity
…rity Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
… and the H0 double refuses unsupported where shapes Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 53 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 143 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 78322724546f1547336cd714f971d0e307668105 && git checkout 78322724546f1547336cd714f971d0e307668105
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8e028591857980ae69b9f9badb380dfa61367e62 172d03701c7652fc84f6087d7723453049cb965d && git checkout -B drift-repro 8e028591857980ae69b9f9badb380dfa61367e62 && git merge --no-ff 172d03701c7652fc84f6087d7723453049cb965d
node scripts/docs-audit/affected-docs.mjs --json 8e028591857980ae69b9f9badb380dfa61367e62
|
Contract reviewServed-tier: ① Derived judgmentsGate readings on the head, read at 16:35 UTC — 34 check-runs, newest per name: 28 (1) The organization source — a data-scope fix, and the dispatcher's new source is exactly
No (2)
(3) The published surface. Right. Runtime accept-set changes the diff implies — each right, each a census cell: ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL |
… /meta read seam The book-tree route's corpus projection and the doc locale collapse now live in packages/rest/src/meta-item-read-gate.ts (createMetaBookTreeAnswer, and resolveDocLocale on the list, item and tree paths), so doc/description, doc/translations and doc/tags name that file. Nothing else in the ledger moves. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…spatcher-meta-item-parity
Contract reviewServed-tier: ① Derived judgmentsGate readings on the head, read at 17:23 UTC — 34 check-runs, newest per name, every DELTA, the one required change (round-0 record DELTA, the merge of (1) The organization source: a data-scope fix, and the dispatcher's new source is exactly (2)
(3) The published surface. Right. Runtime accept-set changes the diff implies, unchanged from round 0, each right and each a census cell: ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20408
Clause-②: yes
The runtime dispatcher's
/metadoors now answer whatRestServer's answer, for the item read, the book-tree route and the list, and they scope a caller to the same organization. A host that mounts only the${prefix}/*catch-all serves/metathrough the dispatcher:createHonoApp, or any adapter written on the publicHttpDispatcherAPI. ADR-0076 item 9 keeps that catch-all as the fallback. Triage's direction was to extend the shared seam (AGENTS.md 〈Route & surface ownership〉 rule 1: one implementation, two transports). This PR extends the seam PR #20404 built inpackages/rest/src/meta-item-read-gate.ts, and builds no second one.First: the organization source was a cross-organization data-scope defect (H0, measured)
The card's first read-at-source item. Triage said a cross-org difference outranks the six rows, and it does differ.
/metadoor took its organization fromdeps.resolveActiveOrganizationId, which returns the auth session'sactiveOrganizationIdunchanged.RestServerreads the vetted value. It readsctx.tenantIdoff the execution context.resolveAuthzContextvets that value: under a wall-enforcing posture, it DROPS a claim naming an organization the caller no longer belongs to.Measured through
dispatch()againstRestServer. Both run the REAL identity resolution (resolveExecutionContext/computeExecCtx→resolveAuthzContext) under anisolatedposture. The subject isu_exmember: the session is stampedorg_alpha, and the onlysys_memberrow isorg_beta. Both principals hold one shared permission set, so only the organization claim separates the arms. Onb28550818:RestServerGET /meta/view/lead_allAlpha pipeline(org_alpha's overlay)All leads(env-wide)GET /meta/viewAlpha pipelineAll leadsGET /meta/view/lead_all?preview=draftAlpha pipelineAll leadsGET /meta/view/lead_all/publishedAlpha pipelineAll leadsGET /meta/view/lead_all?state=draft200, org_alpha's pending draft404 NO_DRAFTGET /meta/_drafts['alpha_board']['env_board']PUT /meta/view/lead_all(manage_metadata)org_alphaThe last row is a WRITE into the left organization's partition.
Controls, green on both transports: a current member reads its own organization, the double gates a non-overridable type's phantom row, and the ex-member switched to
org_betareadsorg_beta.The fix, in the seam:
metaCallerOrganizationId(caller)answers the vettedtenantId.metaReadOrganizationId(type, caller)answersorganizationIdForMetaReadover the folded type and that value.RestServer's list and item reads ask the second, and so does every dispatcher/metaread.PUT,_draftsand/publishedtake the first. That is whatRestServer's twins hand down (ctx.tenantId).meta.tsno longer callsdeps.resolveActiveOrganizationId.Pinned in
packages/runtime/src/domains/meta-read-org-scope-parity.test.ts: 11 tests, 7 red at the base and all green on the fix.The write door is a bounded in-place fix. The read doors are the card's scope; the
PUTrow goes beyond it, and all four conditions hold:meta.tsis this claim's file;The pin's
PUTrow is its evidence:['org_alpha']against[undefined]at the base, equal on the fix.The six rows, and what the census found beside them
Each row was re-measured first, and every one still reproduced on
b28550818. The census inmeta-list-projection-parity.test.tsnow has item, book-tree and cache-posture blocks. Like the list block, each is derived fromRestServer's handler: the query parameters it reads and the type literals it keys on, plus the shared functions it calls.GET /meta/totally_invented_type)200 []against400 INVALID_REQUEST?preview=DRAFT404against200) and 4 item body cellstranslationsmap kept, no collapse)GET /meta/book/:name/tree404 ROUTE_NOT_FOUNDagainst200/403;401against200for an anonymous reader of thepublicbook)?preview=draftCache-Controlon an undetermined postureVary: Accept-LanguagesortabilityThe last two rows are same-family divergences the item census found that the card does not list. The dispatcher's item answer carried no
Vary, and an object schema came with nosortability(#10235). The item chain closes both by construction.H1 was measured, and it differed: the list, the item read,
/publishedand the legacy one-segment object read all served an undetermined posture's unmasked schema with noCache-Control.What changed
The item read is one chain,
createMetaItemAnswer. EverythingRestServer'sGET /meta/:type/:namedoes after the store read moved there, unchanged:itemrequired and the rest pin declares a 404 #18066, before the gate);translateMetaEnvelope: the translation, andsortabilitybeside an object schema.RestServer's uncached arm calls the chain. So does every exit of the dispatcher's item read: the object branch, the generic branch, theMetadataServicefallback and the?state=draftread.RestServer.translateMetaItemandtranslateMetaEnvelopedelegate to the newtranslateMetaDocumentandtranslateMetaEnvelope. The cached arm keeps calling them.Row 6: an admitted
?preview=draftmakes the dispatcher's object branch ask the protocol first, as a scoped kernel always did. That protocol read now carries the requestRestServersends:?package=and the switch.Row 2: both
?preview=declarations inmeta.tsparse the value case-insensitively, asRestServer's do. The draft-door ledger inmeta-draft-read-builder-gate.test.tsnow names the new spelling.Row 1:
refuseUnknownMetaListTypemoved into the seam, unchanged, docblock included.RestServer's private method is a one-line delegate. The dispatcher's list branch asks it before any listing work. It fails open when the live type listing cannot be read, so a host with nogetMetaTypeskeeps the legacy one-segment object read.Row 5: the tree route's whole handler moved into
createMetaBookTreeAnswer: the reads, THEDocsAudience, the §6.7 gate, the locale collapse and the narrowed tree. The dispatcher servesGET /meta/book/:name/tree, with the type segment literal as onRestServer, andmetaReadRouteOfnames itbook-treefor the sharedisPublicAudienceRead.RestServer's two tree-only delegates (audienceBooksOf,resolveDocsAudience) went with it.H1: the list chain applies the object mask itself.
MetaListAnswerSources.maskObjectsbecameresolveObjectMasker. That port is new in this same release, withcreateMetaListAnswer.projectMetaObjectSchemaprojects each schema, so both transports make one decision.MetaListAnswerreportscacheControl, andRestServer's list writes it from the answer; its port used to write it./published, the legacy read) use the same projection. One helper inmeta.ts(successWithHeaders) carries the headers, socheck:route-envelope'shandBuilt: 2is unchanged.RestServer's answers are unchanged: every existing REST test passes unedited.Runtime pins that moved, and why:
/meta/book/public_guide/tree, now/meta/books/public_guide/tree: the singular spelling is a route here now.?preview=site spelling.meta-write-org-scope.test.tsandmeta-save-capability-gate.test.ts. Their execution context carried notenantIdwhile their auth session named an organization. That pairing is exactly the "dropped claim" state, which only the old raw-claim source read as org-scoped. Each now hands the organization on the execution context, as the real resolver does with no wall. 11 cases went red, and none of their assertions changed.Evidence
Red first. The census and pins at
410141ec34, on base sources: census259 failed | 247 passed (506), H07 failed | 4 passed (11).Reverse verification. The final tests, run against the BASE sources (the four source files restored from
b28550818into the tree only):267 failed | 263 passed (530)across the census, H0 and ledger files. Restored withgit checkout HEAD --, with proof: each blob equals HEAD's, andgit diff HEADis empty.Ablations at
7903048a75go throughscripts/ablation-replace.mjs: the anchor hit once, and each mutation landed and was restored with blob == HEAD3519d199a54cand an emptygit diff HEAD. The subject resolves through relative imports and the runtime vitest alias topackages/rest/src, so nodistwas involved. Suite: the census plus the H0 pins, 517 tests.refuseUnknownMetaListType11 failed | 506 passed: the 11totally_invented_typetests, nothing elsetranslateMetaEnvelopesortabilitycells40 failed | 477 passed:object/objectsinvoice 16 each, and the zh-CN cells ofapp/crm,apps/crm,app/helpdesk,page/home2 failed | 515 passed: the item read and its?preview=draftrowGates, all on head
cf85a44ad5(after mergingorigin/mainate956924e1)pnpm --filter @objectstack/rest test: 216 files passed;3912 passed | 26 skipped.test:repo: 1 file,8 passed.pnpm --filter @objectstack/runtime test: 284 files passed;4084 passed | 1 skipped.test:repo: 3 files,575 passed, the census included.pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck: exit 0, andcheck:test-typecheckis OK on both.pnpm lint(eslint . --no-inline-config, the whole repo): exit 0.node scripts/check-issue-citations.mjs --base origin/main: exit 0. The first read was unauthenticated and answered403/ exit 3 (PREREQUISITE NOT MET), so it was re-run with an authenticated read.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 62 commands derived, every one run on this head, all exit 0.check:dual-build-cjs-loadsfirst needed 8 missingdist/s built.--rananswers62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero …).@objectstack/hono5 files / 122 tests;@objectstack/http-conformance8 files / 102;@objectstack/clientclient.hono,client-url-conformanceandmeta-delete-item-carriers, 27; and six/metadogfood files, 72 (meta-published-and-state-routes,route-ledger-live-mount-parity,showcase-anonymous-deny-surfaces,showcase-object-extension-meta-read,dashboard-designer-roundtrip,meta-types-create-seed). All green.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
Acceptance notes
?layers=on the dispatcher's item read.GET /meta/app/crm?layers=truethroughdispatch()answers200 {type, name, item}, the plain read.RestServeranswers the three-layer{type, name, code, overlay, effective}withDeprecation: true. The dispatcher serves no layered view at all:/meta/app/crm/layersanswers a located404 ROUTE_NOT_FOUND, which is loud. The flag, though, is silently a different representation. The item census nameslayersas its one declared exclusion (ITEM_PARAMS_NOT_SERVED_HERE), so every other new parameter still reddens it.domains/packages.tscallsdeps.resolveActiveOrganizationIdat 9 sites (publish-drafts, commits, uninstall, revert, duplicate-adopt, the export sweep). Not measured here. ReadingexecutionContext.tenantIdinHttpDispatcher.resolveActiveOrganizationIditself would close the class for every domain. That ishttp-dispatcher.ts, outside this claim.?package=asRestServer's does. The census double does not discriminate packages on item reads, so no cell moves on it.scripts/check-route-envelope.mjs'smeta.tsledger note still describes the second hand-built site as "the /meta/:type list answer". It is nowsuccessWithHeaders, which every/metaread answer that owes a header goes through. The count (2) holds, and the gate is green. The script is not in this claim; its next editor carries it.RestServer's item, list and tree handlers refuse a repeated single-valued parameter (refuseRepeatedQueryParams), and the dispatcher's/metadomain has no such gate. PR fix(rest, runtime): the dispatcher's /meta reads answer what RestServer's answer (#20320) #20404 recorded the list half.Generated by Claude Code