Skip to content

fix(runtime,rest): the dispatcher /meta item reads ask the per-caller read gate RestServer asks (#20193) - #20236

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20193-dispatcher-meta-read-gate
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20193-dispatcher-meta-read-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20193

Clause-②: yes

The runtime dispatcher's /meta item read and its /published read now use the per-caller read gate that RestServer uses. It is one function with two callers. RestServer's gate moved unchanged into packages/rest/src/meta-item-read-gate.ts, and handleMetadataRequest calls it. There is no second audience resolver in packages/runtime, as ruling 5793362670 item 1 requires.

#20156 remains open (its pending decision on the partial app cells of /layers, ?layers=true and /diff is untouched here). #20139 remains open.

The measurement that picked route A

Triage said the choice between B and A is a measurement. Here it is.

Host census: which in-repo host compositions reach handleMetadataRequest's item branch

host composition REST mounted? who answers GET /meta/:type/:name can REST be mounted there?
@objectstack/hono createHonoApp (published; README and content/docs/plugins/packages.mdx name it the edge / serverless adapter) no the dispatcher: app.all(prefix + '/*'), then dispatch(), the domain registry, and handleMetadataRequest No, as shipped. RestServer registers on an IHttpServer (http.server service). createHonoApp builds a bare Hono app whose catch-all is terminal by design (ADR-0076, open question 9), and @objectstack/hono does not depend on @objectstack/rest.
a thin adapter on the public HttpDispatcher API (packages.mdx: "build a thin adapter on the public HttpDispatcher API") no the dispatcher (dispatch() / handleMetadata()) No, by construction
plugin-hono-server + createRestApiPlugin + createDispatcherPlugin: the CLI's serve / dev (packages/cli/src/commands/serve.ts) and plugin-dev yes RestServer. createDispatcherPlugin mounts explicit routes and no /meta route, so the dispatcher item branch is never reached already mounted
packages/qa/http-conformance node:http reference adapter (private QA) yes RestServer already mounted
packages/verify handle no never reaches /meta (it dispatches /automation and /actions only) n/a
cloud hosts (objectstack-ai/cloud, per ADR-0076 item 9: plugin routes with the createHonoApp catch-all underneath) yes RestServer first; the dispatcher answers REST misses NOT MEASURED here: another repository

Reading. The documented embed shape cannot mount REST, and ADR-0076 item 9 records the dispatcher's /meta branches as "the cloud fallback fabric, not dead code", not killable while the catch-all stands. So B (retire the dispatcher's item reads) would remove a published answer (meta.getItem / meta.getPublished in the route ledger) from a documented host, and would reverse an accepted ADR. A is the route: one transport-neutral gate that both transports call. C is ruled out by 5793362670.

What the two item reads serve (B's scope, for the record)

  • Dispatcher item branch: GET / HEAD /meta/:type/:name, /published, ?package=, ?preview=draft, and a MetadataService.getItem fallback. The body is enveloped { success, data }. Objects get the ADR-0106 mask on the plain read.
  • REST: the same two reads, plus /layers, ?layers=, /history, /audit, /diff, /references, ?state=draft, locale collapse (resolveDocLocale / translation), and ETag / 304 on the cached arm. The body is the bare { type, name, item, … } envelope.

Route A retires nothing, so no answer is dropped. The item branch keeps every one of its own parameters.

Before and after

All rows are driven through dispatch(), the delegate of createHonoApp's catch-all: identity resolution, the domain registry and the handler. The caller is an authenticated member who does not hold crm_admin, on the fixtures of the REST door census (meta-alternate-door-read-gates.test.ts). The "before" column is at base 7e7fab73. The "after" column is at head 0fcb064a2, and the first five rows were also re-read through a real createHonoApp app (app.request(…), a real HttpDispatcher, as a one-off probe that is not committed).

request (non-holder) before after RestServer, same caller
GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED 403 PERMISSION_DENIED
GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED 403 PERMISSION_DENIED
GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED 403 PERMISSION_DENIED
GET /meta/app/crm 200, 3 entries 200, [nav_leads] 200, [nav_leads]
GET /meta/app/crm/published 200, 3 entries 200, [nav_leads] 200, [nav_leads]
GET /meta/book/admin_guide/published 200 403 PERMISSION_DENIED 403
GET /meta/app/payroll (+ /published) 200 403 PERMISSION_DENIED 403
GET /meta/app/launchpad (unpublished, + /published) 200 404 RESOURCE_NOT_FOUND, byte-identical to a missing name on this transport 404 RESOURCE_NOT_FOUND
GET /meta/dashboard/ops (+ /published, holder too) 200, both widgets 200, [w_open_cases] 200, [w_open_cases]
GET /meta/object/invoice/published 200, [amount, secret_margin] 200, [amount] 200, [amount]
GET /meta/docs/crm_admin_runbook (plural) 200 + body 403 PERMISSION_DENIED 403

Controls, unchanged: a holder reads the doc body (both doors), the book, and the whole app. GET /meta/object/invoice is masked for the member and served whole to the exempt holder. An anonymous caller gets 401 UNAUTHENTICATED before any read.

Through the real createHonoApp, at head: non-holder doc / doc /published / book → 403 PERMISSION_DENIED with no secret on the wire; app and app /published → 200 [nav_leads]. Holder: 200 with the doc and book bodies, and all 3 app entries.

How

  • packages/rest/src/meta-item-read-gate.ts (new). createMetaItemReadGate(sources, metaType, name, documents, policy) is the former RestServer#metaItemReadGate body. The helpers it calls came with it unchanged: filterAppForUserWithReason, filterDashboardForUser, resolveDocsAudience, resolveAudienceCaller, the fault-reporting books and doc-corpus reads, resolveRegisteredServices, resolveNavServability, resolveNavDocAudience and loadObjectItems. Its verdict is data (serve, or refuse with absent / app-permission / docs-audience). Each transport supplies only I/O (MetaItemReadGateSources): the caller, a list read, the security service, a service probe, and a prune-log dedupe set.
  • RestServer keeps every private helper name as a one-line delegate, so its list routes, book tree and doors call the same code. metaItemReadGate maps the data verdict to the emitters it always used (sendMetaItemAbsent, sendError, sendDeclaredFault). REST's answers are byte-for-byte unchanged: the 196-case door census and the rest of the package's 3,558 tests pass.
  • handleMetadataRequest calls the gate on whichever lookup answers the item read and /published. The call sits outside the lookups' own swallowing trys, so a gate fault is answered as that fault and never as "not found". It uses policy { arms: 'all', app: 'gate' }, the same one RestServer's plain read and /published use. Refusals use the dispatcher's own envelope with RestServer's status and code. absent is the same deps.error('Not found', 404) a missing name gets.
  • @objectstack/rest exports createMetaItemReadGate and its types. This is the same pattern as repeatedQueryParamMessage: the decision travels, nothing transport-shaped does.

Also closed in the same claimed branch (bounded in-place fix, all four conditions hold). The dispatcher's /published did not apply the ADR-0106 object mask that its own plain read applies. GET /meta/object/invoice/published served secret_margin to a member whose readable set is [amount] (row above). This is the same defect class: this transport's /meta item doors skipping a per-caller read gate that RestServer applies. The fix is mechanical: the dispatcher's existing maskObjectSchema call, ordered after the gate as in RestServer's /published. It is in the claimed /published branch and pinned by the same census. The card's own reading assumed "objects are masked here". That held for the plain read and not for /published.

Tests (head 0fcb064a2)

  • Pin: packages/runtime/src/domains/meta-item-read-gate-parity.test.ts, 77 cases. It drives the same fixtures through dispatch() and through RestServer. For each caller (holder, non-holder, anonymous) it asserts equal status + code and the same served document (nav ids, widget ids, field names, secrets present or absent) on the plain read and on /published, for doc, book, app ×3, dashboard, object and view. It also asserts the reference answers RestServer gives, plus five controls.
  • pnpm --filter @objectstack/runtime exec vitest run --project local: 280 files, 3986 passed, 1 skipped.
  • pnpm --filter @objectstack/rest exec vitest run --project local: 200 files, 3558 passed, 1 skipped.
  • pnpm --filter @objectstack/rest typecheck and pnpm --filter @objectstack/runtime typecheck: exit 0. Both test layers are OK, with the runtime debt ledger unchanged.
  • The execctx-consumer-census counts move by the gate's relocation: 70 → 68 sites and 93 → 92 mentions. Three same-line-caught sites left with the gate, and one caught caller port replaced them. Each number carries its arithmetic in the test.

Ablation. The fix was committed first. Each leg went through scripts/ablation-replace.mjs (WRAP mode, anchor hit 1 → 0, blob changed), with a shell trap restore, and each restore was proven by blob equals HEAD and an empty git diff HEAD. The subject resolves from src: ../http-dispatcher.js, and @objectstack/rest is aliased to src in the runtime vitest config, so no dist leg applies.

  • Gate leg (gateMetaItemDocument serves without judging): 16 red. These are all 14 non-holder / dashboard parity rows of the before-table plus the plural and unpublished-app controls. The object /published row stays green because it is the mask's.
  • Mask leg (/published's if (publishedMasker) disabled): 1 red, GET /meta/object/invoice/published × non-holder.
  • Restore leg, unmutated: 77/77 green. The direction was the expected one: red.

Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 70 commands for these 9 paths. All 70 exited 0 at 0fcb064a2, and --ran reconciled them: 70 derived, 70 run, 0 NOT-MEASURED (a derived zero from recorded exit codes). Also at that head:

  • pnpm lint: full run, exit 0, 142 s.
  • node scripts/check-issue-citations.mjs --base origin/main: exit 0.
  • These roster and wide-population gates, which read these directories, all exited 0: check-changeset-fixed, check-published-list-mirrors, check:error-code-casing, check:route-ledger-census, check:published-readme-exports, check:meta-type-normalized, check:init-service-contract, check:startup-registry-verdict, check:wildcard-fallthrough, check:optional-error-sink, check:filter-alias-parity, check:console-injection, check:i18n-stale-fill.

check:doc-authoring first went red for a moved string. The nav-prune log line carried [#7912] into the new file. The id now sits in an adjacent // comment, and scripts/doc-authoring-prose-id.baseline.json shrinks by that one pinned pair (--census-ledger, shrink only).

Patch round 1 (head acb99baa9)

Contract review 5856229893 answered FAIL on 0fcb064a2, and the seat's REWORK is 5856245736. This round makes three changes and merges origin/main (6d38c526f, then acb99baa9).

  1. Liveness ledger re-anchored. This fixes the Test Core (1/6) red: 「8 anchored citation(s) name a symbol the cited file does not contain」. Each pointer was re-measured against the site that now reads the key, and its entry is stamped verifiedAt: 2026-09-27.
    • app.json navigation.requiredPermissions and navigation.requiresService now point at meta-item-read-gate.ts#filterNav.
    • book.json name now points at meta-item-read-gate.ts#deriveImplicitPackageBook (the bookNamed lookup).
    • doc.json name now points at meta-item-read-gate.ts#resolveDocAudiences (docReader: audiences.get(docName)). order and group point at meta-item-read-gate.ts#docCorpusOf, the list-branch corpus projection.
    • doc.json description and tags point at rest-server.ts#readableTree. The tree route's { name, label, description, order, group, tags, packageId } projection stayed in rest-server.ts, and the new file does not name description at all: the key-mention check refused that first repoint. So the pointer names the site that reads the key. tags also points at meta-item-read-gate.ts#resolveBookTree.
    • Also moved, although they still resolved as text (a docblock mention in rest-server.ts satisfied the anchor):
      • app.json requiredPermissions, _unpublished and the file's _note now point at meta-item-read-gate.ts#filterAppForUserWithReason;
      • book.json audience now points at meta-item-read-gate.ts#audienceAllows (admitsBook);
      • dashboard.json widgets.requiresService now points at meta-item-read-gate.ts#filterDashboardForUser.
    • Left alone: row 18 of docs/adr/0056-permission-model-landing-verification.md cites rest-server.ts#filterAppForUser. That is still a live declaration (the delegate REST's list route calls), and the file is a governed surface.
    • Results: pnpm --filter @objectstack/spec run check:liveness exit 0 (「758 pointer(s) written path#symbol, 758 naming a symbol the cited file contains」). scripts/liveness/check-liveness.test.ts: 64 passed, where 21 were red in CI. All scripts/liveness/ tests: 252 passed (local project) and 81 passed (repo project).
  2. Clause-②: yes. @objectstack/rest's only export subpath gains createMetaItemReadGate and five types: MetaItemReadGateSources, MetaItemReadVerdict, MetaItemReadRefusal, MetaReadGateCaller and MetaReadGatePolicy.
  3. Composed-host pin, committed: packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts, 7 cases. It boots a real LiteKernel and the real createHonoApp, and drives them with app.request(...).
    • Non-holder: the doc, the doc's /published and the set-gated book answer 403, with error.code PERMISSION_DENIED, success: false and no secret on the wire. The crm app answers 200, pruned to [nav_leads].
    • The holder control is served all four in full.
    • Why this package and not packages/adapters/hono: that package's vitest.config.ts aliases @objectstack/runtime to a stub (src/__mocks__/runtime.ts) for every test. createHonoApp imports HttpDispatcher from that specifier, so a host composed there would compose the stub. http-conformance is where the repo already boots the two for real (hono-dispatcher-result-response.conformance.test.ts): @objectstack/hono is aliased to source, and the runtime resolves through dist/, a ledgered pair in check-test-source-alias.
    • Per-PR tier: @objectstack/http-conformance is in turbo ls --affected for this diff, because it depends on @objectstack/runtime and @objectstack/hono. Test Core's PR shards therefore collect it; they exclude only @objectstack/dogfood.
    • Ablation (through dist/). The mutate leg replaced the gate call verdict = await judge(document); with a serve-as-stored verdict carrying the marker ablated20193, then rebuilt the runtime. ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs. Result: 4 failed | 3 passed, exactly the four non-holder rows red and the holder controls green. The restore leg put the file back (blob equals HEAD, git diff HEAD empty), rebuilt, and --absent found no marker with a clean tree. Result: 7/7. Both legs ran at b58d036ed and again at acb99baa9.

Verification at acb99baa9:

  • runtime local tests: 281 files, 4016 passed, 1 skipped;
  • rest local tests: 201 files, 3576 passed, 1 skipped;
  • @objectstack/hono: 5 files, 122 passed; @objectstack/http-conformance: 7 files, 96 passed;
  • both typechecks: exit 0;
  • pnpm lint: exit 0;
  • node scripts/check-issue-citations.mjs --base origin/main: exit 0;
  • dispatch-gates --commands derived 77 commands, all ran, and --ran reconciled 77 of 77 with exit codes (0 NOT-MEASURED).

Changeset

.changeset/20193-dispatcher-meta-read-gate.md:

  • @objectstack/runtime: patch. A fix in a released package (Post-Task Checklist step 3).
  • @objectstack/rest: minor. It adds public exports (createMetaItemReadGate and five types), the same bump the changeset for repeatedQueryParamMessage's publication took. REST's own behaviour is unchanged.

Clause-②: yes: @objectstack/rest's published export surface widens. Its only export subpath gains createMetaItemReadGate and five types, which are public because the runtime dispatcher consumes this one gate (precedent #17672 / PR #17815). The dispatcher's refusals themselves are not the widening: they pull a second transport back to the gate the contract already declares (ADR-0046 §6.7, ADR-0045 §3, ADR-0106, apps.mdx's requiredPermissions row). No accept set widens, and nothing authorable moves. ADR anchor added: scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json (ADR-0045, ADR-0046).

Acceptance notes


Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

59 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 ab820016b3e9691870e24a9bbb867336ee5e8f72.

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

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/liveness/app.json, packages/spec/liveness/book.json, packages/spec/liveness/dashboard.json, …) — pages documenting those are invisible to this run
  • 3 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 — 142 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 ab820016b3e9691870e24a9bbb867336ee5e8f72 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 75c2018ddd494ab06a91f960287d11aafa427eec — the merge of head acb99baa9f2df16a0cf9af5b249e3aa97ac9228f into base ab820016b3e9691870e24a9bbb867336ee5e8f72, 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 75c2018ddd494ab06a91f960287d11aafa427eec && git checkout 75c2018ddd494ab06a91f960287d11aafa427eec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab820016b3e9691870e24a9bbb867336ee5e8f72 acb99baa9f2df16a0cf9af5b249e3aa97ac9228f && git checkout -B drift-repro ab820016b3e9691870e24a9bbb867336ee5e8f72 && git merge --no-ff acb99baa9f2df16a0cf9af5b249e3aa97ac9228f

node scripts/docs-audit/affected-docs.mjs --json ab820016b3e9691870e24a9bbb867336ee5e8f72

⚠️ 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 ab820016b3e9691870e24a9bbb867336ee5e8f72 → 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: 0fcb064a27f5989c02c63085da85b844eefa4fb2

① Derived judgments

  • Move is byte-faithful (modulo port-shaping): correct. Normalized line diff of the 1,107 removed rest-server.ts lines against packages/rest/src/meta-item-read-gate.ts: every residual is a port signature, not a rule. p.getMetaItems?.(objectsRequest) → source.listMetaItems('object'); resolveExecCtx(...).catch(rethrowAuthzStoreUnavailable) → sources.resolveCaller() (RestServer's port metaReadAudienceSources keeps the .catch); securityServiceProvider(environmentId) → resolveSecurityService(); the kernel/serviceExistsProvider probe → serviceProbeFor with the same two branches and null fallback; the emitter closures → data refusals that RestServer.sendMetaReadRefusal maps to the same sendMetaItemAbsent / sendEnvelopeError(403, PERMISSION_DENIED) / sendDeclaredFault(401|403) with the same messages. filterAppForUserWithReason (ordering unpublished → permission → service → filterNav/filterAreas), filterDashboardForUser, resolveDocsAudience, resolveAudienceCaller, resolveRegisteredServices, resolveNavServability, resolveNavDocAudience are unchanged. One text change: the prune log [REST] [#7912] nav entry … → [REST] nav entry … (no test pins it). RestServer's plain read and /published still call { arms: 'all', app: 'gate' } (rest-server.ts head :6960, :8553). 196-case door census: CI shard 3 meta-alternate-door-read-gates.test.ts (196 tests) green, @objectstack/rest 200 files / 3558 passed / 1 skipped.
  • Dispatcher answers the four rows as RestServer does: correct. packages/runtime/src/domains/meta.ts serveItem (item branch) and servePublished (/published) both call gateMetaItemDocument → createMetaItemReadGate(..., RENDERED_DOCUMENT_POLICY = { arms: 'all', app: 'gate' }), outside the lookups' swallowing trys. Pin meta-item-read-gate-parity.test.ts (77 cases) green in CI shard 2 (@objectstack/runtime 280 files / 3986 passed): doc, doc /published, book → 403 PERMISSION_DENIED; app crm → pruned [nav_leads] on both doors; payroll → 403; launchpad (unpublished) → 404 with a body toEqual the missing-name body (refusal.reason === 'absent' → the same deps.error('Not found', 404)); holder control served whole; plural /meta/docs/:name → 403 (singularType fold); MetadataService.getItem fallback wrapped then gated (serveItem({ type, name, item: data })); ?package= / ?preview=draft still reach getMetaItem (:1029), gate reads env-wide active books exactly as RestServer's metaListSource does. Anonymous: shouldDenyAnonymous at meta.ts:440 refuses 401 before any read, the same @objectstack/core rule RestServer's enforceAuth (rest-server.ts:2256) uses. Fault path: listMetaItems answering undefined makes readMetaList throw; gateMetaItemDocument catches and answers deps.errorFromThrown(e, 500), never the document — fail closed as RestServer.
  • No second resolver: correct. metaItemReadGateSources supplies only I/O (context.executionContext, deps.resolveService(context,'security') — the same resolution resolveObjectMasker uses in the file —, protocol.getMetaItems({ type }), a resolveService != null probe, a dedupe Set). Nothing in packages/runtime reads audience, requiredPermissions or holdings; error.code comes from details.code promotion in buildApiError (error-envelope.ts:118-120), so 403/401 carry PERMISSION_DENIED/UNAUTHENTICATED from the gate's data.
  • Object mask on dispatcher /published: within the card. The card names the /published branch as a landing site; os-dev.md:63 four conditions hold (same defect class — a /meta item door on this transport skipping a per-caller gate that RestServer's /published applies since fix(rest): one per-caller read gate for GET /meta/:type/:name and every door beside it #20190; mechanical — the file's existing resolveObjectMasker/maskObjectSchema, ordered gate → mask as rest-server.ts main :9488-9500; no other claim — [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156's residue is /layers, ?layers=, /diff app cells; same gate family — one parity census, mask leg ablated 1 red). Named in the PR body with its row.
  • Route A over B: correct. packages/adapters/hono/package.json depends on plugin-hono-server, runtime, types only — no @objectstack/rest; createHonoApp builds new HttpDispatcher(options.kernel) and app.all(\${prefix}/*`)→dispatcher.dispatch(...) (index.ts:725-739); the registry carries createMetaDomainprefix/meta (http-dispatcher.ts:831, meta.ts:163-165), so GET /meta/:type/:namereacheshandleMetadataRequest. RestServerregisters on anIHttpServer; the bare Hono has none. createDispatcherPluginmounts no/meta` route. ADR-0076 OQ9 (:279-280): catch-all not retired, branches are "the cloud fallback fabric, not dead code" — B would reverse it. Cloud row honestly NOT MEASURED. C excluded by ruling 5793362670 item 1.

② Semver level

  • @objectstack/rest: minor — correct. @objectstack/runtime: patch — correct. Runtime: a permission correction pulling a second transport back to the declared contract (ADR-0046 §6.7, apps.mdx); execution-duties.md:97-98 「运行时权限/安全行为变更不是条款②」「拉回已声明契约不触它」; precedent PR fix(rest): one per-caller read gate for GET /meta/:type/:name and every door beside it #20190 (same defect class on RestServer's doors) shipped patch, Clause-②: no; ruling 5793362670 graded a permission-boundary tightening no. No (narrowing) arm is owed: no published accept set narrows, the 404 for an unpublished app is the transport's existing absence answer.
  • Declaration line: Clause-②: no is WRONG; the correct line is Clause-②: yes. packages/rest/src/index.ts adds one value export and five type exports to the only export subpath of a published package = 公开导出面增 (lanes/spec.md:19), and 「放宽接受集或扩大公开面的卡,不论多小,即条款②」 (lanes/spec.md:21; the question the reader answers, clause2-line.mjs:70: 「本卡放宽接受集或扩大公开面吗」). Repo precedent for exactly this shape and package — PR fix(runtime): a repeated ?version= on GET /packages/:id answers 400 VALIDATION_ERROR from the one shared rule, and @objectstack/rest publishes it (#17672) #17815, the repeatedQueryParamMessage barrel publication the PR itself cites for its minor — declared Clause-②: yes (「⛔ NOT downgraded: packages/rest's published surface widens on either outcome」) and review record 5644634770 upheld it. yes takes at least minor (AGENTS.md Post-Task Checklist 3): the bump already agrees; the declaration does not.

③ Boundary flags

  • scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json: required companion of a new ADR-citing file (check:adr-anchors) — in scope. scripts/doc-authoring-prose-id.baseline.json: shrink-only (−1, #7912 in rest-server.ts) — a consequence of the move, in scope. execctx-consumer-census.test.ts: 70→68 sites / 93→92 mentions / caught 25→23 / inline floor 16→14 with the arithmetic in-file; 28 tests green in shard 3 — in scope.
  • Log text: [#7912] dropped from the operator-facing line, id kept in the adjacent // comment; forced by check:doc-authoring; git grep "pruned from app" over packages/**/*.test.ts = 0 pins. Acceptable, disclosed.
  • PR body claims: 77 / 3558 / 3986 / 196 counts all match CI logs. The "real createHonoApp" after-reading is an uncommitted probe — NOT MEASURED here. The committed pin drives dispatch(), not a composed catch-all host as triage note 3 required (meta-verb-fallthrough.test.ts also imports only HttpDispatcher despite its docblock). The claimed "70 derived gates all 0" is true but the roster never derived packages/spec's check:liveness / test, so the red below was not seen locally.
  • CI on the head: NOT GREEN, and PR-caused. 28 success, 3 skipped, 2 failure, 1 in progress (Lint & Repo Gates) at review time. Test Core (1/6) failed: @objectstack/spec scripts/liveness/check-liveness.test.ts 21 failed — the gate reports 「8 anchored citation(s) name a symbol the cited file does not contain」: packages/spec/liveness/app.json:99,:111 (rest-server.ts#filterNav), book.json:8 (#deriveImplicitPackageBook), doc.json:8,:32,:38 (#resolveDocAudiences), doc.json:20,:50 (#resolveBookTree). Those four symbols count 11/3/7/6 in rest-server.ts on main and 0 on the head (all now in meta-item-read-gate.ts); the ledger rows are unchanged on the branch. main (fc9123941) passed the same shard at 13:04Z.
  • Out-of-scope LIST finding: CONFIRMED at source. meta.ts:1198-1240 (parts.length === 1): protocol.getMetaItems(...) → maskObjectSchemaList (ADR-0106 object mask only) → slimDocList (keeps bodies under ?include=content) → served. No createMetaItemReadGate, filterAppForUser or docs-audience call on that branch, where RestServer's list route prunes. Not this PR's required change; it needs its own card in the [finding] the runtime dispatcher's /meta item reads apply NO per-caller read gate: through a catch-all host, GET /meta/doc/:name serves a permission-set-gated doc body to a non-holder, and /meta/app/:name serves requiredPermissions-gated entries #20193 family.

Implemented-by: claude/issue-20193-dispatcher-meta-read-gate
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: FAIL

  • Re-anchor the eight packages/spec/liveness/{app,book,doc}.json evidence pointers from packages/rest/src/rest-server.ts#{filterNav,deriveImplicitPackageBook,resolveDocAudiences,resolveBookTree} to packages/rest/src/meta-item-read-gate.ts#…, measured (read the declaration, confirm it still reads the key) with verifiedAt stamped, per the gate's own repair text; amend the claim's file surface for packages/spec/liveness/** in the same round (os-dev.md:65). Test Core (1/6) must be green on the new head before the PR is armed.
  • Change the PR body declaration to Clause-②: yes (the @objectstack/rest public export surface widens; precedent fix(runtime): a repeated ?version= on GET /packages/:id answers 400 VALIDATION_ERROR from the one shared rule, and @objectstack/rest publishes it (#17672) #17815 / record 5644634770). Bumps stay: @objectstack/rest minor, @objectstack/runtime patch.
  • Close the gap against triage execution note 3: commit the four-row non-holder pin through a composed createHonoApp host (app.request(...) in packages/adapters/hono, which may import both createHonoApp and the runtime) with the holder control, or record the maintainer's acceptance of the dispatch()-level pin in the PR body.

…; declare Clause-② yes

The move to packages/rest/src/meta-item-read-gate.ts took filterNav,
filterAppForUserWithReason, filterDashboardForUser, deriveImplicitPackageBook,
resolveDocAudiences, resolveBookTree and the admitsBook audienceAllows call
with it, so the liveness evidence pointers follow, each re-measured against
the site that now reads the key and stamped verifiedAt 2026-09-27.

The changeset now declares Clause-② yes: @objectstack/rest's only export
subpath gains createMetaItemReadGate and five types, which the runtime
dispatcher consumes.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
…createHonoApp host

A non-holder of crm_admin is refused the gated doc, its /published twin and
the set-gated book (403 PERMISSION_DENIED, nothing on the wire) and served
the crm app pruned of its requiredPermissions-gated entry; a holder is the
control. Lives here, not in packages/adapters/hono, because that suite aliases
@objectstack/runtime to a stub for every test.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: acb99baa9f2df16a0cf9af5b249e3aa97ac9228f

Delta of: 5856229893 (FAIL on 0fcb064a)

① Derived judgments

  • Required change 1, liveness ledger: correct, every re-anchored pointer TRUE. Each new symbol read at the head: meta-item-read-gate.ts#filterNav (nested closure inside filterAppForUserWithReason, :652) reads e.requiredPermissions (:656-657) and e.requiresService (:658) — both app.json navigation rows true. #filterAppForUserWithReason (:612) reads item._unpublished (:636) and item.requiredPermissions (reqApp, :639) — app.json requiredPermissions / _unpublished true. #audienceAllows names the call admitsBook: (book) => audienceAllows(book?.audience, caller) (:485) — book.json audience true. #deriveImplicitPackageBook names bookNamed's books.find(b => b.name === name) ?? deriveImplicitPackageBook(name, name) (:483-484), and rest-server.ts:6470 is audience.bookNamed(req.params.name) — book.json name true. #resolveDocAudiences names the call at :477 whose map is asked audiences.get(docName) (:478) over the corpus docCorpusOf projects with name: d.name — doc.json name true (a call-site anchor, the same shape the pre-move rest-server.ts#resolveDocAudiences pointer had). #docCorpusOf (:329) reads order: d.order, group: d.group (:337-338) — doc.json order / group true. rest-server.ts#readableTree: the tree route projection description: d.description … tags: d.tags (:6493-6500) feeds audience.readableTree(book, docs) (:6509) — doc.json description / tags true; the gate file names description 0 times, so the dev's key-mention account is true, and it names tags once (:336) so the second tags anchor #resolveBookTree (:488) passes. #filterDashboardForUser (:831) reads w.requiresService (:835) — dashboard.json true. verifiedAt stamped 2026-09-27 on every re-anchored entry, per the gate's repair text (check-liveness.mts:1509-1511: re-anchor at the real symbol and stamp verifiedAt). Edits: 25 insertions / 25 deletions across the four files — evidence + verifiedAt pairs, plus one pointer token inside app.json _note (rest-server.ts#filterAppForUserWithReason → meta-item-read-gate.ts#…, prose otherwise identical); git diff --stat ab820016b acb99baa9 -- packages/spec lists only those four files. Measured in a temporary worktree at the head: pnpm --filter @objectstack/spec run check:liveness exit 0, 「758 pointer(s) written path#symbol, 758 naming a symbol the cited file contains」, key-mention 613 anchored / 1 exempt. CI: Test Core (1/6) success (14:18:13Z); the gate job's merged timing table records scripts/liveness/check-liveness.test.ts (local) 64 tests measured in this run.
  • Required change 2, exports and declaration: correct. packages/rest/src/index.ts (vs main) adds exactly export { createMetaItemReadGate } and export type { MetaItemReadGateSources, MetaItemReadRefusal, MetaItemReadVerdict, MetaReadGateCaller, MetaReadGatePolicy }; all six are declared in meta-item-read-gate.ts (:1201, :92, :116, :130, :52, :137). The changeset names the same six and no others. Frontmatter: @objectstack/runtime: patch, @objectstack/rest: minor. Changeset carries a line-initial Clause-②: yes; the PR body's line 3 is Clause-②: yes (the only line-initial Clause-② line).
  • Required change 3, composed-host pin: correct. packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts boots a real LiteKernel (kernel.use + bootstrap) with protocol and security services and calls the real createHonoApp({ kernel, prefix }), driving app.request(...). The package's vitest.config.ts aliases only /^@objectstack\/hono$/ to adapter source; @objectstack/runtime resolves through dist/ (its index.js contains createMetaItemReadGate, 2 hits). The one seam stubbed is identity: vi.spyOn(HttpDispatcher.prototype, 'timedResolveExecutionContext') — a real private method (http-dispatcher.ts:996, called at :610), so the catch-all, registry, handleMetadataRequest, gate and rendering are real. Rows: non-holder doc, doc /published, set-gated book → 403 PERMISSION_DENIED, success:false, secret absent from the wire; crm app → 200 pruned to [nav_leads] with getMetaItem called once; holder controls: doc + /published bodies, book, app with both entries. Run in the worktree against the built closure: 7/7 passed, 20 s. Per-PR tier, reproduced: TURBO_SCM_BASE=ab820016b turbo ls --affected = 76 packages including @objectstack/http-conformance; cross-package union adds none; partition-test-shards.mjs … --exclude @objectstack/dogfood (the only exclusion, ci.yml:554) places it on shard 5/6 beside @objectstack/cli 1/2 and @objectstack/objectql, which is what the CI shard 5/6 log tail shows running; turbo.json test.dependsOn: ["^build"] builds the runtime dist first; the filter job's core globs include packages/**. CI Test Core (5/6) success. The dev's reason for not using packages/adapters/hono is true: its vitest.config.ts aliases '@objectstack/runtime' (bare-string prefix find) to src/__mocks__/runtime.ts for the whole suite. Ablation account consistent with the code: verdict = await judge(document); occurs exactly once (meta.ts:382, inside gateMetaItemDocument); a serve-as-stored verdict leaves the three refusal rows at 200 and the app row unpruned (4 red) while the holder rows stay 200 (3 green), and since the pin reads the runtime through dist/, a rebuild is required — as reported.
  • ④ Nothing the previous review judged correct moved. meta-item-read-gate.ts, rest-server.ts, index.ts, meta.ts, meta-item-read-gate-parity.test.ts, execctx-consumer-census.test.ts, the ADR-anchor JSON and the prose-id baseline are blob-identical between 0fcb064a and the head. Between the old merge base 805af4f29 and the new ab820016b, main touched none of them (only 615c46874 added packages/rest/src/data-query-epoch-ms-date-comparand.test.ts). CI shard 3: src/domains/meta-item-read-gate-parity.test.ts (77 tests) green, @objectstack/runtime 281 files; shard 4: src/meta-alternate-door-read-gates.test.ts (196 tests) green, @objectstack/rest 201 files. Byte-faithful move, dispatcher parity and single resolver stand as judged.

② Semver level

③ Boundary flags

  • packages/spec/liveness/dashboard.json:162-166 re-anchored although the REWORK named {app,book,doc} only; the amended claim 5855082696 now lists {app,book,doc,dashboard}.json, anchor lines only. Covered. The _note token in app.json:3 is a path#symbol pointer, not prose; disclosed as deviation 3. Acceptable.
  • app.json _note still carries the pre-existing prose citation rest-server.ts:2651-2740 for filterAppForUser; that was already stale on main (declaration at main :3564, head delegate :3207) and the gate parses no path:NNN from notes (0 line citations). Not this PR's.
  • docs/adr/0056…md:70 row 18 rest-server.ts#filterAppForUser untouched: still resolves (delegate at head :3207). Governed surface, correctly left alone.
  • MetaItemReadGateSources extends MetaReadGateAudienceSources and MetaReadGateListSource, which are module exports but not barrel exports; structurally usable, check:dts-closure and both type-check gates green. Note only.
  • Out-of-scope LIST-branch finding from the prior review is filed as [finding] the runtime dispatcher's /meta LIST reads apply no per-caller gate: through a catch-all host, GET /meta/doc?include=content lists a set-gated doc body, /meta/book lists a set-gated book, /meta/app lists gated apps and entries #20237 and named in the PR body. Not this card.
  • CI on acb99baa9 (check-runs API): 42 runs, 37 success, 5 skipped (Auto Label ×1, Check PR Size ×1 duplicates from the 15:00 rerun, Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 0 failure, 0 in progress. Test Core gate attested 6/6; Test Core (1/6) success; Spec property liveness success; Lint & Repo Gates success; TypeScript Type Check and all four type-check legs success; Check Changeset success.
  • Local measurements were taken in a temporary worktree under my scratch directory at the head sha (removed afterwards); the shared checkout was not used or modified. No GitHub write, no MCP write; GitHub MCP reads were used only for job logs and artifact URLs (the artifact blob host is policy-denied by the proxy, so shard placement was reproduced from the head's own selection scripts rather than read from the artifact).

Implemented-by: claude/issue-20193-dispatcher-meta-read-gate
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: PASS

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… list prunes, through one list gate (objectstack-ai#20237) (objectstack-ai#20319)

Fixes objectstack-ai#20237

Clause-②: yes

The runtime dispatcher's `/meta/:type` LIST now prunes what
`RestServer`'s `GET /meta/:type` prunes, for the same caller. It is one
function with two callers. `RestServer`'s per-caller list filters moved
unchanged into `createMetaListReadGate`, beside the item gate in
`packages/rest/src/meta-item-read-gate.ts`. `RestServer`'s list route
and the dispatcher's one-segment list branch both call it. There is no
second audience resolver in `packages/runtime` (ruling `5793362670` item
1). This is the route objectstack-ai#20193's item half took in PR objectstack-ai#20236.

objectstack-ai#20139 remains open.

## The mechanism assumptions, measured first

### 1. `RestServer`'s LIST filters, classified

In handler order (`GET ${metaPath}/:type`, after
`getMetaItems(listRequest)`):

| filter | class | dispatcher before | now |
|:--|:--|:--|:--|
| `api` served-set face (`selectServedEndpoints`) | per-deployment
(projection of what the endpoint matcher serves) | not run | not run
(out of scope; see Acceptance notes) |
| `app` nav filter (`filterAppForUser`: unpublished,
`requiredPermissions`, docs-audience entry arm, plus the ADR-0057 D10
and objectstack-ai#7912 servability arms) | **per-caller** (with per-deployment arms
riding along) | not run | **the shared list gate** |
| `app` `?id=` filter | projection (query narrowing, not a gate) |
ignored | ignored (out of scope; see Acceptance notes) |
| `dashboard` widget gate (ADR-0057 D10) | per-deployment | not run |
**the shared list gate** (the card's direction names it) |
| `view` `?object=` filter | projection | ignored | ignored (out of
scope) |
| `book` audience prune (ADR-0046 §6.7) | **per-caller** | not run |
**the shared list gate** |
| `doc` effective-audience prune (ADR-0046 §6.7) | **per-caller** | not
run | **the shared list gate** |
| `doc` locale collapse, translation | projection | not run | not run
(out of scope) |
| `doc` content slim (`?include=content`) | projection | run
(`slimDocList`), singular spelling only | unchanged |
| ADR-0106 object mask | **per-caller** | run on the protocol exit only
| run on **every** list exit |

The object mask stays on each transport's own exit, as the item gate
documents: both already call the one mask in
`@objectstack/metadata-core`. The projections withhold nothing from any
caller, so they are not this card.

### 2. The dispatcher's list branch

Confirmed at source on `2dccb7d4`: `handleMetadataRequest`,
`parts.length === 1`, had no per-caller filter anywhere on its path. It
has **three** list exits, not one:

- the protocol's `getMetaItems`, masked for objects and slimmed for
docs;
- a fallback to the runtime metadata service's `list`, reached when the
protocol does not answer the type. It was slimmed only: no gate, and
**no object mask**;
- a fallback to the ObjectQL registry's `listItems`. It had no gate, no
mask and no slim; for `object` it answers `getAllObjects()`, unmasked.

The mask ran inside the protocol exit's swallowing `try`. A mask fault
other than the D6 tier-3 one therefore fell through to the unmasked
metadata-service exit.

### 3. The rows, re-measured before the fix

Driven through `dispatch()` (the `createHonoApp` catch-all's delegate)
at base `2dccb7d4`, against the same fixtures and caller as
`RestServer`'s list route. The caller is a member without `crm_admin`:

| request | dispatcher before | `RestServer`, same caller | dispatcher
now |
|:--|:--|:--|:--|
| `GET /meta/doc?include=content` | `crm_intro`, **`crm_admin_runbook`
with its body** | `crm_intro` | `crm_intro` |
| `GET /meta/book` | **`admin_guide`** (with its description),
`help_center` | `help_center` | `help_center` |
| `GET /meta/app` | **`crm` with 3 entries, `payroll`, `launchpad`** |
`crm`, pruned to `nav_leads` | `crm`, pruned to `nav_leads` |
| `GET /meta/dashboard` (any caller) | `ops`, **both widgets** | `ops`,
`w_open_cases` | `ops`, `w_open_cases` |
| `GET /meta/object` | `invoice`, masked to `amount` | the same | the
same |
| `GET /meta/docs?include=content`, `/meta/books`, `/meta/apps` | as
their singular rows | as their singular rows | as their singular rows |

- **Holder:** every row equals `RestServer` before and after.
- **Anonymous:** the dispatcher answers `401 UNAUTHENTICATED` on every
list, before any read, both before and after. For `doc` and `book` lists
`RestServer` instead serves the `public` entries (none in this fixture).
The dispatcher is stricter there; see Acceptance notes.

### 4. The objectstack-ai#20193 lessons

- **Liveness:** no gate symbol moved out of its file. The list filters
were inline blocks, not named symbols, so no pointer broke. `pnpm
--filter @objectstack/spec run check:liveness`: exit 0, 760 of 760
`path#symbol` pointers resolve, and no ledger file was edited.
- **Pin location:** the composed-host pin sits beside objectstack-ai#20193's, in
`packages/qa/http-conformance/src/`.
- **Exports:** one new value export, `createMetaListReadGate`. It takes
the item gate's existing `MetaItemReadGateSources`, so no new type is
exported. That keeps `Clause-②: yes` and makes `@objectstack/rest`
`minor`; `@objectstack/runtime` is `patch`.

### 5. The exec-ctx census

68 → 66 sites and 92 → 90 mentions. The list route's app and dashboard
blocks each resolved the context inline (two same-line CAUGHT sites).
Both moved into the gate, which reads the caller through the port the
item gate already uses, so nothing replaced them. The census now reads:
CAUGHT 23 → 21, same-line 15 → 13, bare 45 unchanged, and the inline
floor 14 → 12, keeping its one site of slack. The arithmetic is in the
same hunks.

## How

- **`createMetaListReadGate(sources, metaType)`**
(`meta-item-read-gate.ts`) answers a judge from a list's items to the
items this caller may be served. It is `RestServer`'s former inline list
filters, in the same order and with the same inputs, and it is built
from the functions already in the module (`filterAppForUser`,
`filterDashboardForUser`, `resolveRegisteredServices`,
`resolveNavServability`, `resolveNavDocAudience`, `resolveDocsAudience`,
`fetchAudienceBooks`, `docCorpusOf`). It answers the same array when
nothing applies, and a gate input that cannot be read rejects.
- **`RestServer`'s list route** calls it once, at the position where the
app filter always ran: before `?id=` narrows, and before the doc locale
collapse. It rewraps the result only when the gate pruned something. Its
list answers are unchanged: the package's 3,662 local tests pass, the
list-route tests among them.
- **The dispatcher's list branch** sends every list exit through
`gateMetaListAnswer`, which runs the shared gate and then the ADR-0106
mask. The lookups keep their own `try`s, which classify a type the store
does not know. The gate and the mask run outside them, so a fault is
answered as itself (its own status, or `500` for a shapeless one) and
never falls through to the next store. Each exit keeps its own
projection (the doc slim) exactly as before.
- **`RestServer` delegates.** Seven private delegates lost their only
caller with the move (`filterAppForUser`, `filterDashboardForUser`,
`resolveRegisteredServices`, `resolveNavServability`,
`resolveNavDocAudience`, `fetchAudienceBooks`, `docCorpusOf`). They are
deleted: `noUnusedLocals` reports each one as TS6133, and a delegate
with no caller is a second place to read a rule that nothing runs.
- 83 unit-test call sites in `rest.test.ts`,
`meta-app-nav-servability-gate.test.ts` and
`meta-app-nav-doc-audience.test.ts` reached those delegates through
`any`. They now call the one implementation directly. Assertions are
unchanged, and the `it()` counts are unchanged at 215, 16 and 18.
- **`filterAppForUser` stays resolvable at `rest-server.ts`** as a named
re-export, because row 18 of
`docs/adr/0056-permission-model-landing-verification.md` cites
`packages/rest/src/rest-server.ts#filterAppForUser`. Measured: deleting
the declaration turns `check:adr-symbol-anchors` `unresolved-symbol` on
that row, and that ADR is a governed surface this PR does not touch. The
re-export is not on the package barrel; see Acceptance notes.

## Tests

- **`packages/runtime/src/domains/meta-list-read-gate-parity.test.ts`**,
40 cases. It drives the same fixtures through `dispatch()` and through
`RestServer`'s `GET /meta/:type`:
  - the reference answers `RestServer` gives the non-holder (11);
- dispatcher equals `RestServer`, for holder and non-holder, on every
row (22): the same status and code, the same items, and the same nav
entries, widgets, fields and served bodies per item;
- controls (4): the holder served in full; anonymous `401` before any
read; `RestServer`'s anonymous doc and book lists serving no gated
content; and a books-read fault answered as that fault;
- the fallback exits (3): the metadata-service exit prunes doc, book and
app lists and masks objects; the registry exit masks objects; the holder
is served in full.
-
**`packages/qa/http-conformance/src/hono-meta-list-read-gate.conformance.test.ts`**,
6 cases, through a real `LiteKernel` behind the real `createHonoApp`
(`app.request(...)`). For the non-holder, the card's three rows: the doc
list with bodies lists `crm_intro` only, and the gated doc's name and
body are nowhere on the wire; the book list lists `help_center` only;
the app list lists `crm` pruned to `nav_leads`, with `payroll` absent.
The holder control lists everything in full. Only identity is stubbed,
and the runtime resolves through `dist/`.

**Ablation** (the fix committed first at `34f2588dd`;
`scripts/ablation-replace.mjs` in WRAP mode, plus a shell trap restoring
from `HEAD`):

- **Mutate leg.** `gateMetaListAnswer`'s gate call `const judged = await
createMetaListReadGate(...)(list);` was replaced by a pass-through
carrying the code marker `ablated20237`. The anchor count went 1 → 0 and
the blob `e5cbe6ba` → `74221a11`. After a runtime rebuild,
`ablation-dist-preflight` found the marker in `dist/index.js` and
`dist/index.cjs`.
- Composed-host pin: **3 failed, 3 passed**. These are exactly the three
non-holder rows; the holder rows stay green.
- Parity pin: **11 failed, 29 passed**. Red: every non-holder doc, book
and app row (both spellings), the dashboard rows for both callers (the
gate is per-deployment), the books-fault control and the
metadata-service exit. The object rows stay green, because the mask is
not the gate.
- **Restore leg.** The blob equals `HEAD` (`e5cbe6ba`) and `git diff
HEAD` is empty. After a rebuild, `--absent` found no marker in any of
the 6 built files, and the whole-tree `git status --porcelain` was
empty. Result: **6 of 6** and **40 of 40**.
- The direction was the predicted one: red.

**Verification at head `a48f373a2`** (after merging `origin/main` at
`6a6a17b62`, merge commit `8c972a5c4`):

- `pnpm --filter @objectstack/rest exec vitest run --project local`: 202
files, 3662 passed, 1 skipped. `--project repo`: 1 file, 8 passed.
- `pnpm --filter @objectstack/runtime exec vitest run --project local`:
282 files, 4057 passed, 1 skipped. `--project repo`: 2 files, 69 passed.
Run at `8c972a5c4`; the only later commit, `a48f373a2`, touches two rest
test files that no runtime suite reads.
- `pnpm --filter @objectstack/http-conformance test`: 8 files, 102
passed. Run at `8c972a5c4`, for the same reason.
- `typecheck` for rest (at `a48f373a2`), runtime and http-conformance
(at `8c972a5c4`): exit 0 each. The test layers are OK: rest at 0 errors,
and the runtime and http-conformance debt ledgers unchanged.
- `pnpm --filter @objectstack/spec run check:liveness`: exit 0 (760 of
760).
- `pnpm lint` (full, `eslint . --no-inline-config`): exit 0, 114 s on a
shared box.
- `node scripts/check-issue-citations.mjs --base origin/main`: exit 0,
board probed, 17 citations judged, all resolve.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 61 commands for these 11 paths. All 61 exited 0, and
`--ran` reconciled them: 61 derived, 61 run, 0 NOT-MEASURED (a derived
zero from recorded exit codes).
- Near-diff gates, run beside the derived set, all exit 0:
`check:liveness`, `check:adr-symbol-anchors`, `check:adr-anchors`,
`check:scripts-symbol-anchors`, `check-changeset-fixed`,
`check-published-list-mirrors`, `check:error-code-casing`,
`check:route-ledger-census`, `check:published-readme-exports`,
`check:meta-type-normalized`, `check:init-service-contract`,
`check:startup-registry-verdict`, `check:wildcard-fallthrough`,
`check:optional-error-sink`, `check:filter-alias-parity`.

## Changeset

`.changeset/20237-dispatcher-meta-list-gate.md`:

- **`@objectstack/rest`: `minor`.** One new public export,
`createMetaListReadGate`, consumed by the runtime dispatcher's `/meta`
list. `RestServer`'s answers are unchanged.
- **`@objectstack/runtime`: `patch`.** The dispatcher's list is pulled
back to the per-caller contract the platform already declares (ADR-0046
§6.7, ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row: 「absent from
the `/meta` body」). A permission pull-back is not a Clause-② widening.

Who could read what before: on a host that mounts only the catch-all,
any authenticated member could list a `{ permissionSet }`-gated doc with
its body, a set-gated book's name and description, an app whose
`requiredPermissions` they lack, and an ungated app's gated nav entries.
On a host whose protocol does not answer a type, the fallback exits also
served object schemas unmasked.

## Acceptance notes

- **Out of scope, same family (the dispatcher's `/meta` list is a second
implementation of `RestServer`'s), measured through `dispatch()` after
the fix.** None of these serves anything a caller may not read, because
the gate prunes first. They are reported for the seat, not fixed here:
- `GET /meta/app?id=crm` ignores `?id=`: the holder is listed all three
apps, where `RestServer` answers `[crm]`;
- `GET /meta/view?object=nope` ignores `?object=` and lists every view,
where `RestServer` answers `[]`;
- `GET /meta/docs`, the plural spelling without `?include=content`,
serves the doc bodies, because `slimDocList` compares the raw segment
with `'doc'`. `RestServer` slims both spellings;
- the doc locale collapse, translation and the `api` served-set face are
not run.
- **Anonymous `public` docs and books are unreachable on a
catch-all-only host.** The dispatcher answers `401` to every anonymous
`/meta` read. With a `public` book claiming `crm_intro`, `RestServer`
lists `public_guide` and `crm_intro` to an anonymous caller, and the
dispatcher answers `401 UNAUTHENTICATED`. That is fail-closed, so it is
not this card; it is reported for the seat.
- **ADR-0056 row 18** still cites `rest-server.ts#filterAppForUser`. The
rule lives in `meta-item-read-gate.ts#filterAppForUser`, and
`rest-server.ts` re-exports it by name so the pointer keeps landing one
hop from it. Re-anchoring that row is a governed-surface edit for the
maintainer; the re-export goes when the row moves.
- **Fault paths, fail closed.** A gate or mask fault on the dispatcher's
list is now answered as that fault, not by falling through to the next
store. A host whose protocol has no `getMetaItems` answers a doc list
with the fault rather than the unfiltered list, the same requirement the
item gate and `RestServer` already have.
- **Deviations from the suggested route**, each measured:
- the object mask stays per transport, not inside the seam (the item
gate's documented split);
- seven `RestServer` delegates were deleted and their unit tests
retargeted (the `noUnusedLocals` consequence of the move);
  - no liveness or ADR-anchor file moved.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

2 participants