Skip to content

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

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20408-dispatcher-meta-item-parity
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20408-dispatcher-meta-item-parity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20408
Clause-②: yes

The runtime dispatcher's /meta doors now answer what RestServer'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 /meta through the dispatcher: createHonoApp, or any adapter written on the public HttpDispatcher API. 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 in packages/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.

  • The dispatcher read the session claim as stored. Every dispatcher /meta door took its organization from deps.resolveActiveOrganizationId, which returns the auth session's activeOrganizationId unchanged.
  • RestServer reads the vetted value. It reads ctx.tenantId off the execution context. resolveAuthzContext vets that value: under a wall-enforcing posture, it DROPS a claim naming an organization the caller no longer belongs to.
  • So a removed member kept the left organization's partition on the dispatcher, for the rest of the session.

Measured through dispatch() against RestServer. Both run the REAL identity resolution (resolveExecutionContext / computeExecCtx → resolveAuthzContext) under an isolated posture. The subject is u_exmember: the session is stamped org_alpha, and the only sys_member row is org_beta. Both principals hold one shared permission set, so only the organization claim separates the arms. On b28550818:

door (as the ex-member) dispatcher RestServer
GET /meta/view/lead_all Alpha pipeline (org_alpha's overlay) All leads (env-wide)
GET /meta/view Alpha pipeline All leads
GET /meta/view/lead_all?preview=draft Alpha pipeline All leads
GET /meta/view/lead_all/published Alpha pipeline All leads
GET /meta/view/lead_all?state=draft 200, org_alpha's pending draft 404 NO_DRAFT
GET /meta/_drafts ['alpha_board'] ['env_board']
PUT /meta/view/lead_all (manage_metadata) write lands in org_alpha write lands env-wide

The 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_beta reads org_beta.

  • The fix, in the seam:

    • metaCallerOrganizationId(caller) answers the vetted tenantId.
    • metaReadOrganizationId(type, caller) answers organizationIdForMetaRead over the folded type and that value.
    • RestServer's list and item reads ask the second, and so does every dispatcher /meta read.
    • The dispatcher's PUT, _drafts and /published take the first. That is what RestServer's twins hand down (ctx.tenantId).
    • meta.ts no longer calls deps.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 PUT row goes beyond it, and all four conditions hold:

    • the same defect class: the same source, the same file;
    • a mechanical, pinned shape;
    • meta.ts is this claim's file;
    • the same gate families.

    The pin's PUT row 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 in meta-list-projection-parity.test.ts now has item, book-tree and cache-posture blocks. Like the list block, each is derived from RestServer's handler: the query parameters it reads and the type literals it keys on, plus the shared functions it calls.

# row before, on the census fixtures
1 unknown type (GET /meta/totally_invented_type) 33 list cells: 200 [] against 400 INVALID_REQUEST
2 ?preview=DRAFT 6 list cells, plus 2 item cells (404 against 200) and 4 item body cells
3 item translation 48 item cells
4 doc item locale 84 item cells (the translations map kept, no collapse)
5 GET /meta/book/:name/tree 64 of 80 tree cells (404 ROUTE_NOT_FOUND against 200/403; 401 against 200 for an anonymous reader of the public book)
6 object ?preview=draft 8 item cells (the active schema)
H1 Cache-Control on an undetermined posture 20 list cells, 16 item cells
new item Vary: Accept-Language 400 item cells (header only)
new object sortability 64 item cells

The 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 no sortability (#10235). The item chain closes both by construction.

H1 was measured, and it differed: the list, the item read, /published and the legacy one-segment object read all served an undetermined posture's unmasked schema with no Cache-Control.

What changed

  • The item read is one chain, createMetaItemAnswer. Everything RestServer's GET /meta/:type/:name does after the store read moved there, unchanged:

    1. absence ([finding] GET /meta/app/NAME answers 200 with an item-less envelope for a nonexistent name — the spec declares item required and the rest pin declares a 404 #18066, before the gate);
    2. THE item gate under the door's policy;
    3. the ADR-0046 doc locale collapse;
    4. the ADR-0106 mask, under the posture resolved before the fetch;
    5. the body, translateMetaEnvelope: the translation, and sortability beside 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, the MetadataService fallback and the ?state=draft read. RestServer.translateMetaItem and translateMetaEnvelope delegate to the new translateMetaDocument and translateMetaEnvelope. The cached arm keeps calling them.

  • Row 6: an admitted ?preview=draft makes the dispatcher's object branch ask the protocol first, as a scoped kernel always did. That protocol read now carries the request RestServer sends: ?package= and the switch.

  • Row 2: both ?preview= declarations in meta.ts parse the value case-insensitively, as RestServer's do. The draft-door ledger in meta-draft-read-builder-gate.test.ts now names the new spelling.

  • Row 1: refuseUnknownMetaListType moved 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 no getMetaTypes keeps the legacy one-segment object read.

  • Row 5: the tree route's whole handler moved into createMetaBookTreeAnswer: the reads, THE DocsAudience, the §6.7 gate, the locale collapse and the narrowed tree. The dispatcher serves GET /meta/book/:name/tree, with the type segment literal as on RestServer, and metaReadRouteOf names it book-tree for the shared isPublicAudienceRead. RestServer's two tree-only delegates (audienceBooksOf, resolveDocsAudience) went with it.

  • H1: the list chain applies the object mask itself.

    • MetaListAnswerSources.maskObjects became resolveObjectMasker. That port is new in this same release, with createMetaListAnswer.
    • The shared projectMetaObjectSchema projects each schema, so both transports make one decision.
    • MetaListAnswer reports cacheControl, and RestServer's list writes it from the answer; its port used to write it.
    • The dispatcher's other mask exits (/published, the legacy read) use the same projection. One helper in meta.ts (successWithHeaders) carries the headers, so check:route-envelope's handBuilt: 2 is unchanged.

RestServer's answers are unchanged: every existing REST test passes unedited.

Runtime pins that moved, and why:

  • The census control's unrouted book path. It was /meta/book/public_guide/tree, now /meta/books/public_guide/tree: the singular spelling is a route here now.
  • The draft-door ledger: the ?preview= site spelling.
  • meta-write-org-scope.test.ts and meta-save-capability-gate.test.ts. Their execution context carried no tenantId while 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: census 259 failed | 247 passed (506), H0 7 failed | 4 passed (11).

  • Reverse verification. The final tests, run against the BASE sources (the four source files restored from b28550818 into the tree only): 267 failed | 263 passed (530) across the census, H0 and ledger files. Restored with git checkout HEAD --, with proof: each blob equals HEAD's, and git diff HEAD is empty.

  • Ablations at 7903048a75 go through scripts/ablation-replace.mjs: the anchor hit once, and each mutation landed and was restored with blob == HEAD 3519d199a54c and an empty git diff HEAD. The subject resolves through relative imports and the runtime vitest alias to packages/rest/src, so no dist was involved. Suite: the census plus the H0 pins, 517 tests.

    ablation predicted measured
    (A) the dispatcher skips refuseUnknownMetaListType exactly the row-1 cells 11 failed | 506 passed: the 11 totally_invented_type tests, nothing else
    (B) the dispatcher's item chain bypasses translateMetaEnvelope the translation and sortability cells 40 failed | 477 passed: object/objects invoice 16 each, and the zh-CN cells of app/crm, apps/crm, app/helpdesk, page/home
    (C) the dispatcher's item read threads the raw claim again the H0 item-read cells 2 failed | 515 passed: the item read and its ?preview=draft row

Gates, all on head cf85a44ad5 (after merging origin/main at e956924e1)

  • 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, and check:test-typecheck is 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 answered 403 / 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-loads first needed 8 missing dist/s built. --ran answers 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero …).
  • Consumers the dispatcher's wire change reaches: @objectstack/hono 5 files / 122 tests; @objectstack/http-conformance 8 files / 102; @objectstack/client client.hono, client-url-conformance and meta-delete-item-carriers, 27; and six /meta dogfood 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.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not 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.

pnpm turbo run build (the runtime closure, the rest package, the consumer closures), pnpm --filter @objectstack/rest test / test:repo, pnpm --filter @objectstack/runtime test / test:repo, pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck, pnpm lint, pnpm --filter @objectstack/hono --filter @objectstack/http-conformance test, the client and dogfood file runs

Acceptance notes

  • Out of scope, measured, reported (class a): ?layers= on the dispatcher's item read. GET /meta/app/crm?layers=true through dispatch() answers 200 {type, name, item}, the plain read. RestServer answers the three-layer {type, name, code, overlay, effective} with Deprecation: true. The dispatcher serves no layered view at all: /meta/app/crm/layers answers a located 404 ROUTE_NOT_FOUND, which is loud. The flag, though, is silently a different representation. The item census names layers as its one declared exclusion (ITEM_PARAMS_NOT_SERVED_HERE), so every other new parameter still reddens it.
  • Out of scope, read at source (possible data leak): the same raw-claim source elsewhere in the dispatcher. domains/packages.ts calls deps.resolveActiveOrganizationId at 9 sites (publish-drafts, commits, uninstall, revert, duplicate-adopt, the export sweep). Not measured here. Reading executionContext.tenantId in HttpDispatcher.resolveActiveOrganizationId itself would close the class for every domain. That is http-dispatcher.ts, outside this claim.
  • Not measured. The object branch's protocol read now threads ?package= as RestServer's does. The census double does not discriminate packages on item reads, so no cell moves on it.
  • A stale note, not a count. scripts/check-route-envelope.mjs's meta.ts ledger note still describes the second hand-built site as "the /meta/:type list answer". It is now successWithHeaders, which every /meta read 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.
  • Repeated query parameters are unchanged here and not measured by this PR: RestServer's item, list and tree handlers refuse a repeated single-valued parameter (refuseRepeatedQueryParams), and the dispatcher's /meta domain 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

hotlong and others added 7 commits September 28, 2026 23:17
…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>
… 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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/rest, @objectstack/runtime, @objectstack/spec, touching 87 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/doc.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

53 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 8e028591857980ae69b9f9badb380dfa61367e62.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/doc.json) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 143 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 8e028591857980ae69b9f9badb380dfa61367e62 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 78322724546f1547336cd714f971d0e307668105 — the merge of head 172d03701c7652fc84f6087d7723453049cb965d into base 8e028591857980ae69b9f9badb380dfa61367e62, 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 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

⚠️ 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 8e028591857980ae69b9f9badb380dfa61367e62 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cf85a44ad53c802de1eb6a950db71d28a475ed25
Local-runs: none

① Derived judgments

Gate readings on the head, read at 16:35 UTC — 34 check-runs, newest per name: 28 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 1 in_progress (Lint & Repo Gates — no verdict inferred), 2 failure: Test Core (1/6) and its roll-up Test Core. Job 109023544453 is @objectstack/spec test:repo: scripts/liveness/evidence.test.ts › "every local path#symbol anchor names a symbol its file contains (#12516)" — Test Files 1 failed | 37 passed, Tests 1 failed | 689 passed. The three anchors it reports "symbol gone": doc/description → packages/rest/src/rest-server.ts#readableTree, doc/translations → packages/rest/src/rest-server.ts#resolveDocLocale, doc/tags → packages/rest/src/rest-server.ts#readableTree. This diff caused it: moving the tree route's whole handler into createMetaBookTreeAnswer took the only readableTree and resolveDocLocale identifier sites out of rest-server.ts (5 hits at the merge base 5b674f52d, 0 at the head; both now sit in packages/rest/src/meta-item-read-gate.ts, lines 2507–2531), while packages/spec/liveness/doc.json — not in the file list, unchanged at the head — still cites the old file in three evidence strings. Wrong: a shipped liveness ledger's anchors rot on this head. The dev's gate list ran rest and runtime test:repo but never spec's, which is where the ledger's evidence test lives; the 62 derived commands did not reach it either.

(1) The organization source — a data-scope fix, and the dispatcher's new source is exactly RestServer's. Right. RestServer.computeExecCtx assembles tenantId: authz.tenantId (rest-server.ts:2949) from resolveAuthzContext; the dispatcher's context.executionContext is resolveExecutionContext (http-dispatcher.ts:610), which assembles tenantId: authz.tenantId from the same resolver (packages/runtime/src/security/resolve-execution-context.ts:217–246). metaCallerOrganizationId(caller) reads that tenantId; metaReadOrganizationId(type, caller) is organizationIdForMetaRead(canonicalMetaUrlType(type), that). Door by door at the head (meta.ts has zero resolveActiveOrganizationId hits; the unlock scan's control read 8 on main):

  • list (meta.ts:1681) metaReadOrganizationId(typeOrName, executionContext) ↔ RestServer list (rest-server.ts:5793) the same function over listCtx — right.
  • item read, object branch (1398) and generic branch (1443) metaReadOrganizationId(type, caller) ↔ RestServer's plain read (hunk -6544/+6358) metaReadOrganizationId(req.params.type, readCtx) — right; the MetadataService fallback and the legacy registry read thread no organization on either transport.
  • ?state=draft (678) the same read source; its author exemption (685) saveVerdict(canonical, metaCallerOrganizationId(caller)) ↔ RestServer.metaSaveVerdict activeOrganizationId: ctx?.tenantId (3404) — right.
  • /published (939) metaCallerOrganizationId(…) raw into getMetaItemLayered ↔ RestServer (8295) publishedCtx?.tenantId raw, the callee gates by type — right.
  • GET /meta/_drafts (1541) metaCallerOrganizationId(ec) ↔ RestServer (5625) ctx?.tenantId ?? undefined — right.
  • PUT (1093) metaCallerOrganizationId(…) into saveVerdict and the unchanged organizationIdForMetaWrite threading below it ↔ RestServer PUT (6877, 6976) ctx?.tenantId — right.
  • book tree: neither transport threads an organization (RestServer's removed booksRequest/docsRequest carried none; the dispatcher's listTreeInput passes type and package only) — right.

No /meta door keeps the raw session claim. The one spelling difference from the old RestServer sites: metaCallerOrganizationId folds a non-string tenantId to undefined where listCtx?.tenantId passed it through; organizationIdForMetaRead tests only === undefined, and both RestServer call sites spread the result under a truthiness guard, so no answer moves. The pin meta-read-org-scope-parity.test.ts (11 tests: 7 defect rows, 3 controls, 1 reference) drives the REAL identity resolution on both transports under posture: 'isolated'; it runs under the runtime local project (src/**/*.test.ts, alias @objectstack/rest → ../rest/src/index.ts, vitest.config.ts:149), which the Test Core shards run — right, not test:repo. The raw claim survives in domains/packages.ts (9 sites) and in HttpDispatcher.resolveActiveOrganizationId itself — outside this claim, escalated in ③.

(2) RestServer unchanged, step by step, where steps moved into the seam. Right on every moved step, and the file list carries no packages/rest/**/*.test.ts: the three rest files are index.ts, meta-item-read-gate.ts, rest-server.ts.

  • refuseUnknownMetaListType + metaTypeIsLive: the moved bodies are byte-equal to the removed private methods, message included; the private method is now a one-line delegate.
  • Item read, uncached arm. Old: absence → metaItemReadGate (sources built with policy.app === 'author-exempt') → resolveDocLocale(visible, extractLocale(req)) → mask (maskObjectDocument on project; Cache-Control: private, no-store on undetermined) → Vary → translateMetaEnvelope. New: createMetaItemAnswer runs the same five in the same order over metaItemAnswerSources, which spreads metaItemReadGateSources(environmentId, req, p, policy.app === 'author-exempt') (3668 — the builder metaItemReadGate itself uses at 3121) and a requestLocale that forwards to this.extractLocale(req, i18n), called with no i18n for the collapse as before. The mask now calls applyObjectSchemaMask under every posture; that function returns the document by reference for any kind but project (metadata-core object-schema-fls.ts:358), so passthrough and undetermined are untouched, and emptied → sendFieldVisibilityFault(res, name) exactly as maskObjectDocument sent it. Refusals go through the same static sendMetaReadRefusal (3144) the old verdict.send closed over; absent → sendMetaItemAbsent, byte for byte.
  • translateMetaItem → translateMetaDocument: same non-object early return, same isTranslatableMetaType, same i18n memo (the cached arm's i18nService preferred), same translationBundleOf / metaTranslateOptions / packagedObjectBaseOf — RestServer.buildTranslationBundle, translateOptionsFor and packagedObjectBase have been one-line delegates to those three since [finding] class closure: the runtime dispatcher's /meta list still diverges from RestServer's off the gate path (?id=, ?object=, plural /meta/docs bodies, locale) and refuses a public audience to anonymous callers #20320 (3434, 3462, 3531), so no rule moved.
  • translateMetaEnvelope: the sortability computation and the { ...envelope, ...sortability, item } shape are identical; the type is folded once before the call instead of inside translateMetaItem — the same set.
  • Book tree: metaItemsArray (seam:428) is the old inline norm; audienceBooksOf and resolveDocsAudience were already the seam's, the two removed delegates only forwarded; the doc header projection is field-for-field the same; the 401/403 messages are DOCS_SIGN_IN_MESSAGE / DOCS_HOLDER_MESSAGE (seam:1246–1247), equal to the old literals; sendDeclaredFault takes the same triple.
  • List: the old maskObjects port's loop is maskMetaObjectList verbatim (tier-3 catch, emptied fault, undetermined flag); the only relocation is the Cache-Control write, from inside the port to answer.cacheControl after the chain — same header, same value, same conditions, still before Vary.
  • Organization id: (1).

(3) The published surface. Right. packages/rest/package.json exports one entry (.), so src/index.ts is the whole surface. The diff adds seven value exports — createMetaBookTreeAnswer, createMetaItemAnswer, metaCallerOrganizationId, metaReadOrganizationId, projectMetaObjectSchema, refuseUnknownMetaListType, translateMetaEnvelope — and five type exports — MetaBookTreeAnswer, MetaBookTreeSources, MetaItemAnswer, MetaItemAnswerSources, MetaItemRequest; nothing removed. The changeset names exactly those twelve, plus MetaListAnswer's optional cacheControl and the MetaListAnswerSources.maskObjects → resolveObjectMasker reshaping. That reshaping breaks no published version: .changeset/20320-dispatcher-meta-read-parity.md is still pending on main, and packages/rest/CHANGELOG.md (17.4.0) names neither createMetaListAnswer nor MetaListAnswerSources. translateMetaDocument and META_UNDETERMINED_CACHE_CONTROL are module exports only, not root — unpublished, rightly absent from the changeset. No repo baseline enumerates rest's root exports (a grep for createMetaListAnswer outside packages/rest/src hits only the 20320 changeset, meta.ts and the census), so none was owed.

Runtime accept-set changes the diff implies — each right, each a census cell: GET /meta/book/:name/tree is a dispatcher route (literal book; books stays ROUTE_NOT_FOUND on both transports), exempt for an anonymous caller through isPublicAudienceRead(…, 'book-tree', …); GET /meta/:unknown-type answers 400 INVALID_REQUEST, fail-open when the protocol has no getMetaTypes; ?preview= compares case-insensitively on the list and the item read; the object branch reads the protocol first on an admitted preview and threads ?package=; Cache-Control: private, no-store on every undetermined-posture object exit; Vary: Accept-Language and sortability on the item read. The draft-door ledger spelling and the books control path moved with them.

② Semver level

@objectstack/rest: minor — right: twelve root exports added, nothing any published version exported removed, renamed or narrowed (verified in ①(3)). @objectstack/runtime: patch — right: no runtime export changes (successWithHeaders, isBookTreePath, answerMetaItem are module-private) and the wire changes are fixes toward the declared RestServer contract. Clause-②: yes (widening) in the changeset; the PR body and the amended claim (5872508709) both carry Clause-②: yes. Check Changeset is success. Level right.

③ Boundary flags

open_questions: []. Deviations and findings, each answered or escalated:

  1. File-surface addendum, packages/runtime/src/meta-write-org-scope.test.ts — answered: the claim was amended in place to name it; the diff changes only the ctx helper and its call sites, no assertion.
  2. Bounded in-place fix beyond the reads (PUT, _drafts, /published) — answered: triage's order (5866725146) ranks the org-id defect first, the amended claim names those doors, and the pin's PUT row is the evidence.
  3. Verification ran unlocked — declared verbatim in the PR body; not a review matter.
  4. Model-free commit trailers — AGENTS.md's rule; noted.
  5. Clause-② yes stands — answered in ②.
  6. Out of scope, class a: ?layers=true on the dispatcher's item read silently answers the plain read — escalate: the seat files the card from the report's dedupe words; the census names layers as its one declared exclusion, so the gap is not silent in the census.
  7. Out of scope, security: domains/packages.ts still reads deps.resolveActiveOrganizationId at 9 sites, and HttpDispatcher.resolveActiveOrganizationId still returns the session claim as stored — escalate as its own card (possible cross-organization read and write on the package doors; this diff closes the class for /meta only).
  8. Stale ledger note in scripts/check-route-envelope.mjs — carrier none; the count holds and the gate is green.
  9. Owed for the FAIL, unanswered: packages/spec/liveness/doc.json — the evidence anchors of doc.description, doc.translations and doc.tags must re-point from packages/rest/src/rest-server.ts to packages/rest/src/meta-item-read-gate.ts (#createMetaBookTreeAnswer, or #readableTree / #resolveDocLocale, which that file now names; the check is identifier-bounded). That path is under packages/spec/**, which the claim declares read-only ("stop on breach and explain in the report"), so the seat amends the claim's file surface first, then the dev repoints and runs pnpm --filter @objectstack/spec test:repo, the derived gate the dev's list missed. Until then Test Core is red on this head.

Implemented-by: claude/issue-20408-dispatcher-meta-item-parity
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: FAIL

hotlong and others added 2 commits September 29, 2026 00:45
… /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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 172d03701c7652fc84f6087d7723453049cb965d
Local-runs: none

① Derived judgments

Gate readings on the head, read at 17:23 UTC — 34 check-runs, newest per name, every head_sha this head: 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 1 in_progress: Test Core (5/6) — no verdict inferred, 0 failure. The shard that was red at round 0, Test Core (1/6) (it carried @objectstack/spec test:repo, the liveness evidence test, job 109023544453 on cf85a44a), is success on this head (run 109039949879, completed 17:20:35Z). Lint & Repo Gates, all four Type Check jobs, Check Changeset, Spec property liveness, the three Dogfood shards and Temporal Conformance are success.

DELTA, the one required change (round-0 record 5874443954, REWORK 5874450249): resolved. The head adds exactly one file to round 0's list, packages/spec/liveness/doc.json (+3/−3; commit 36c0274 touches that file alone). The three hunks change only the path#symbol token of the evidence strings of props.description, props.translations and props.tags: rest-server.ts#readableTree → meta-item-read-gate.ts#createMetaBookTreeAnswer (description, tags) and rest-server.ts#resolveDocLocale → meta-item-read-gate.ts#resolveDocLocale (translations); every status, verifiedAt and note, and every other row, is context, untouched. The check is identifier-bounded and both identifiers are in that file at the head: createMetaBookTreeAnswer is declared at 2503 (named again at 48 and 2453), resolveDocLocale at 2288, 2292, 2434, 2435, 2507 and 2521. The gate that failed says so: Test Core (1/6) is green. Right, and inside the amended claim (5872508709, amended 16:42Z: those three anchors only, on PR #20236's precedent). Nothing else under packages/spec/** moves.

DELTA, the merge of origin/main (172d037, parents 36c0274 and 8e02859, the latter PR #20472's merge commit): clean, both sides intact, no behaviour lost. Measured three ways. (a) The +/− lines of the net diff against main are byte-identical to round 0's (against e956924e1) for all ten files round 0 carried: rest-server.ts 586, meta.ts 536, the seam 586, index.ts 25, the changeset 55, the five test files 5/281/358/23/48; so this PR removes no line #20472 added and lost none of its own. (b) The merge commit against its first parent changes 34 files, all of them origin/main's between e956924e1 and 8e02859, and its rest-server.ts delta is +85/−27, exactly #20472's own patch stats, which a hand-resolved conflict would not reproduce. (c) The head's rest-server.ts: refuseNonAuthoringCaller at 8 sites, the definition (1705) and the /history, /audit, /diff guards (7168, 7361, 7866) each with its docblock mention, against main's 8; mayReadPendingDrafts 16 against 16; this.resolveExecCtx( 66 against 66 (this PR's hunks add or remove no such line, so #20472's updated execctx-consumer-census.test.ts counts the same set); no conflict markers; one definition each of refuseNonAuthoringCaller and of the refuseUnknownMetaListType delegate; and every delegate of this PR present (refuseUnknownMetaListType 2700, translateMetaDocument 3627, translateMetaEnvelope 3659, resolveObjectMasker 3972, metaReadOrganizationId 5826 and 6399, createMetaBookTreeAnswer 6141, createMetaItemAnswer 6610). Line count 13535 = main's 13783 − 248, this PR's own net on the file. Right.

(1) The organization source: a data-scope fix, and the dispatcher's new source is exactly RestServer's on every /meta door the diff touches, reads and writes alike. Right, re-read on this head. meta.ts has 0 hits of resolveActiveOrganizationId (the unlock scan's control: 8 on main), and the 8 removed raw-claim reads are the 8 seam call sites at the head: the ?state=draft read 678 metaReadOrganizationId(type, caller) and its author exemption 685 saveVerdict(…, metaCallerOrganizationId(caller)); /published 939 metaCallerOrganizationId(…); PUT 1093 metaCallerOrganizationId(…) into saveVerdict and the unchanged organizationIdForMetaWrite threading; the item read's object branch 1398 and generic branch 1443 metaReadOrganizationId(type, caller); GET /meta/_drafts 1541 metaCallerOrganizationId(ec); the list 1681 metaReadOrganizationId(typeOrName, …). metaCallerOrganizationId reads the tenantId off the execution context (a string, else undefined); metaReadOrganizationId is organizationIdForMetaRead(canonicalMetaUrlType(type), that). RestServer's twins on this head: computeExecCtx assembles tenantId: authz.tenantId (2982); the list (5826) and the item read (6399) ask the same metaReadOrganizationId; metaSaveVerdict 3437, PUT 6910 and 7009, _drafts 5658 and /published 8353 read ctx?.tenantId. The dispatcher's context.executionContext is resolveExecutionContext's (http-dispatcher.ts), assembled from the same resolveAuthzContext: round 0's read, still the reading, because neither http-dispatcher.ts nor resolve-execution-context.ts is in this PR's file list or among the merge's 34 files. Book tree: neither transport threads an organization (listTreeInput(type, packageId) on both). No /meta door keeps the raw session claim. The pin meta-read-org-scope-parity.test.ts (11 tests, the real identity resolution on both transports under posture: 'isolated', its PUT row included) is byte-identical to round 0's and runs in the Test Core shards. The raw claim survives in domains/packages.ts (9 sites) and in HttpDispatcher.resolveActiveOrganizationId itself, outside this claim (③).

(2) RestServer unchanged, step by step, where steps moved into the seam. Right; the diff read in full on this head, and the file list carries no packages/rest/**/*.test.ts: the eleven files are one changeset, rest/src/index.ts, rest/src/meta-item-read-gate.ts, rest/src/rest-server.ts, runtime/src/domains/meta.ts, five runtime test files and spec/liveness/doc.json.

  • refuseUnknownMetaListType and metaTypeIsLive: bodies and message byte-equal to the removed private methods; the private method is a one-line delegate.
  • The item read's uncached arm. Old: absence, then the gate (sources built with policy.app === 'author-exempt'), then resolveDocLocale(visible, extractLocale(req)), then the mask (project through maskObjectDocument, undetermined writes private, no-store), then Vary, then translateMetaEnvelope(req, req.params.type, …). New: createMetaItemAnswer runs the same five in the same order over metaItemAnswerSources, which spreads metaItemReadGateSources(environmentId, req, p, policy.app === 'author-exempt'), a requestLocale that is this.extractLocale(req, i18n) (called with no i18n for the collapse, as before) and a translateEnvelope handing the RAW segment to the same translateMetaEnvelope; a refusal goes through sendMetaReadRefusal (absent is sendMetaItemAbsent), mask-fault through sendFieldVisibilityFault, then Cache-Control, Vary, the body. The one shape change, applyObjectSchemaMask now running under every posture, is a by-reference passthrough for any kind but project (round 0's read of metadata-core object-schema-fls.ts, a file neither the PR nor the merge touches).
  • translateMetaItem → translateMetaDocument: the non-object early return is the seam's first line; isTranslatableMetaType, the cached arm's i18nService preference, translationBundleOf, metaTranslateOptions and packagedObjectBaseOf (delegates since [finding] class closure: the runtime dispatcher's /meta list still diverges from RestServer's off the gate path (?id=, ?object=, plural /meta/docs bodies, locale) and refuses a public audience to anonymous callers #20320), the protocol resolved with its rejection swallowed, extractLocale(req, i18n): the same. translateMetaEnvelope: sortability on the folded object, the { ...envelope, ...sortability, item } shape: the same, folded once instead of twice (idempotent).
  • The book tree: norm is metaItemsArray (seam 428, the same three branches); booksRequest and docsRequest (type, packageId?, environmentId?) are rebuilt by listTreeInput; resolveDocsAudience(metaReadAudienceSources(environmentId, req), audienceBooksOf(books)) is what the two removed delegates forwarded to; the refusals are DOCS_SIGN_IN_MESSAGE / DOCS_HOLDER_MESSAGE (seam 1246 and 1247, equal to the old literals) with the same status and code through the same sendDeclaredFault; the header projection is field for field; the tree is audience.readableTree(book, docs). refuseRepeatedQueryParams now precedes the locale read, a pure read: the same wire.
  • The list: the maskObjects port's loop is maskMetaObjectList verbatim (tier-3 catch, other throws propagate, emptied fault); the Cache-Control write moved from inside the port to answer.cacheControl after the chain, still before Vary: the same header, value and conditions on every served answer. The one step-order difference is on the error path only: a throw inside the translation step used to leave the header already set on the 500, and no longer does. No test asserts it and no served answer moves; noted, not a defect.
  • The organization: (1).

(3) The published surface. Right. packages/rest/package.json exports one entry (.) at the head, so src/index.ts is the whole root. The diff adds seven value exports, createMetaBookTreeAnswer, createMetaItemAnswer, metaCallerOrganizationId, metaReadOrganizationId, projectMetaObjectSchema, refuseUnknownMetaListType, translateMetaEnvelope, and five type exports, MetaBookTreeAnswer, MetaBookTreeSources, MetaItemAnswer, MetaItemAnswerSources, MetaItemRequest; nothing removed (index.ts +24/−1, the −1 a comment line). The changeset names exactly those twelve, plus MetaListAnswer's optional cacheControl and MetaListAnswerSources.maskObjects → resolveObjectMasker. That reshaping breaks no published version: .changeset/20320-*.md is still pending at 8e02859 (the merge's 34 files add four changesets and trim one, 20106-*, and move no packages/rest/CHANGELOG.md or package.json; rest is still 17.4.0). translateMetaDocument, META_UNDETERMINED_CACHE_CONTROL and metaItemsArray are module exports only, rightly absent; MetaListTranslationSources, which meta.ts now imports from @objectstack/rest, was already a root type export (head index.ts 137, not an added line). minor with Clause-②: yes (widening): right.

Runtime accept-set changes the diff implies, unchanged from round 0, each right and each a census cell: GET /meta/book/:name/tree is a dispatcher route (literal book; books stays ROUTE_NOT_FOUND on both transports), exempt for an anonymous caller through isPublicAudienceRead(…, 'book-tree', …) (MetaPublicReadRoute already carried 'book-tree'); GET /meta/:unknown-type answers 400 INVALID_REQUEST, fail-open when the protocol has no getMetaTypes; ?preview= compares case-insensitively on the list and the item read; the object branch reads the protocol first on an admitted preview and threads ?package=; Cache-Control: private, no-store on every undetermined-posture object exit (list, item, /published, the legacy one-segment read); Vary: Accept-Language and sortability on the item read. @objectstack/runtime publishes nothing new: successWithHeaders, isBookTreePath, answerMetaItem and metaTranslationSources are module-private.

② Semver level

@objectstack/rest: minor, @objectstack/runtime: patch: right (①(3)). Clause-②: yes (widening) in the changeset; Clause-②: yes in the PR body and in the amended claim 5872508709. Check Changeset is success. Unchanged by the round: the changeset's +/− lines are byte-identical to round 0's, and the doc.json repoint publishes nothing.

③ Boundary flags

open_questions: [] in both reports. Each deviation and finding of this round, answered or escalated:

  1. packages/spec/liveness/doc.json under the claim's read-only packages/spec/**: answered. The claim was amended in place (16:42Z) to exactly those three evidence anchors, on PR fix(runtime,rest): the dispatcher /meta item reads ask the per-caller read gate RestServer asks (#20193) #20236's precedent; commit 36c0274 touches that file alone and those three values alone (①).
  2. The PR body was not edited (the dev writes it once): its Gates section still names cf85a44ad5; the seat may append this head's merge and gate lines. Not a review matter; this record carries the head's readings.
  3. The worktree re-added on the existing branch: the commit list is linear, 36c0274 then the merge, on top of cf85a44a; no new branch, no force.
  4. Verification ran unlocked: declared as in round 0; not a review matter.
  5. Carried: meta-write-org-scope.test.ts outside the declared test dirs: answered in round 0 (the claim was amended; the ctx helper and its call sites only, no assertion changed), and its +/− lines are unchanged this round.
  6. check:platform-checklist red on this host: judged by this head's check-runs, none runs it. It is kept out of per-PR CI by decision (checklist-status.yml:18); the watchdog workflow's pull_request trigger is path-filtered to its own file (platform-checklist-watchdog.yml:133–135), and no such check-run exists on this head. A main-only sweep with its own anchor issue; not this card's carrier, and not a gate here.
  7. check:merge-driver red on this host: judged by this head's check-runs, it runs inside Lint & Repo Gates (lint.yml:4673), success on this head (completed 17:17:09Z). Host-environmental, as the dev read it.
  8. Still owed by the seat from round 0, unchanged by this round: the ?layers=true class-a card (the dispatcher's item read silently answers the plain read; the census names layers as its one declared exclusion), the security card for domains/packages.ts's 9 raw-claim sites and HttpDispatcher.resolveActiveOrganizationId itself (this PR closes the class for /meta only), and the stale check:route-envelope ledger note (carrier none; the count holds, the gate is green).
  9. Test Core (5/6) was still running at the read: no verdict inferred. The seat reads the check-runs again before landing.

Implemented-by: claude/issue-20408-dispatcher-meta-item-parity
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 17:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 5c7aa46 Sep 28, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20408-dispatcher-meta-item-parity branch September 28, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

1 participant