Repository navigation
fix(runtime,rest): the dispatcher /meta item reads ask the per-caller read gate RestServer asks (#20193) - #20236
Conversation
…ports Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…spatcher-meta-read-gate
…line shrinks Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 59 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
|
…spatcher-meta-read-gate
…; 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>
…spatcher-meta-read-gate
Contract reviewServed-tier: Delta of: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS |
… 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>
Fixes #20193
Clause-②: yes
The runtime dispatcher's
/metaitem read and its/publishedread now use the per-caller read gate thatRestServeruses. It is one function with two callers.RestServer's gate moved unchanged intopackages/rest/src/meta-item-read-gate.ts, andhandleMetadataRequestcalls it. There is no second audience resolver inpackages/runtime, as ruling5793362670item 1 requires.#20156 remains open (its pending decision on the partial
appcells of/layers,?layers=trueand/diffis 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 branchGET /meta/:type/:name@objectstack/honocreateHonoApp(published; README andcontent/docs/plugins/packages.mdxname it the edge / serverless adapter)app.all(prefix + '/*'), thendispatch(), the domain registry, andhandleMetadataRequestRestServerregisters on anIHttpServer(http.serverservice).createHonoAppbuilds a bare Hono app whose catch-all is terminal by design (ADR-0076, open question 9), and@objectstack/honodoes not depend on@objectstack/rest.HttpDispatcherAPI (packages.mdx: "build a thin adapter on the publicHttpDispatcherAPI")dispatch()/handleMetadata())plugin-hono-server+createRestApiPlugin+createDispatcherPlugin: the CLI'sserve/dev(packages/cli/src/commands/serve.ts) andplugin-devRestServer.createDispatcherPluginmounts explicit routes and no/metaroute, so the dispatcher item branch is never reachedpackages/qa/http-conformancenode:httpreference adapter (private QA)RestServerpackages/verifyhandle/meta(it dispatches/automationand/actionsonly)objectstack-ai/cloud, per ADR-0076 item 9: plugin routes with thecreateHonoAppcatch-all underneath)RestServerfirst; the dispatcher answers REST missesReading. The documented embed shape cannot mount REST, and ADR-0076 item 9 records the dispatcher's
/metabranches 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.getPublishedin 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 by5793362670.What the two item reads serve (B's scope, for the record)
GET/HEAD /meta/:type/:name,/published,?package=,?preview=draft, and aMetadataService.getItemfallback. The body is enveloped{ success, data }. Objects get the ADR-0106 mask on the plain read./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 ofcreateHonoApp's catch-all: identity resolution, the domain registry and the handler. The caller is an authenticated member who does not holdcrm_admin, on the fixtures of the REST door census (meta-alternate-door-read-gates.test.ts). The "before" column is at base7e7fab73. The "after" column is at head0fcb064a2, and the first five rows were also re-read through a realcreateHonoAppapp (app.request(…), a realHttpDispatcher, as a one-off probe that is not committed).RestServer, same callerGET /meta/doc/crm_admin_runbookPERMISSION_DENIEDPERMISSION_DENIEDGET /meta/doc/crm_admin_runbook/publishedPERMISSION_DENIEDPERMISSION_DENIEDGET /meta/book/admin_guide(set-gated)PERMISSION_DENIEDPERMISSION_DENIEDGET /meta/app/crm[nav_leads][nav_leads]GET /meta/app/crm/published[nav_leads][nav_leads]GET /meta/book/admin_guide/publishedPERMISSION_DENIEDGET /meta/app/payroll(+/published)PERMISSION_DENIEDGET /meta/app/launchpad(unpublished, +/published)RESOURCE_NOT_FOUND, byte-identical to a missing name on this transportRESOURCE_NOT_FOUNDGET /meta/dashboard/ops(+/published, holder too)[w_open_cases][w_open_cases]GET /meta/object/invoice/published[amount, secret_margin][amount][amount]GET /meta/docs/crm_admin_runbook(plural)PERMISSION_DENIEDControls, unchanged: a holder reads the doc body (both doors), the book, and the whole app.
GET /meta/object/invoiceis masked for the member and served whole to the exempt holder. An anonymous caller gets401 UNAUTHENTICATEDbefore any read.Through the real
createHonoApp, at head: non-holder doc / doc/published/ book →403 PERMISSION_DENIEDwith no secret on the wire; app and app/published→200 [nav_leads]. Holder:200with 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 formerRestServer#metaItemReadGatebody. The helpers it calls came with it unchanged:filterAppForUserWithReason,filterDashboardForUser,resolveDocsAudience,resolveAudienceCaller, the fault-reporting books and doc-corpus reads,resolveRegisteredServices,resolveNavServability,resolveNavDocAudienceandloadObjectItems. Its verdict is data (serve, orrefusewithabsent/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.RestServerkeeps every private helper name as a one-line delegate, so its list routes, book tree and doors call the same code.metaItemReadGatemaps 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.handleMetadataRequestcalls the gate on whichever lookup answers the item read and/published. The call sits outside the lookups' own swallowingtrys, so a gate fault is answered as that fault and never as "not found". It uses policy{ arms: 'all', app: 'gate' }, the same oneRestServer's plain read and/publisheduse. Refusals use the dispatcher's own envelope withRestServer's status and code.absentis the samedeps.error('Not found', 404)a missing name gets.@objectstack/restexportscreateMetaItemReadGateand its types. This is the same pattern asrepeatedQueryParamMessage: 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
/publisheddid not apply the ADR-0106 object mask that its own plain read applies.GET /meta/object/invoice/publishedservedsecret_marginto a member whose readable set is[amount](row above). This is the same defect class: this transport's/metaitem doors skipping a per-caller read gate thatRestServerapplies. The fix is mechanical: the dispatcher's existingmaskObjectSchemacall, ordered after the gate as inRestServer's/published. It is in the claimed/publishedbranch 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)packages/runtime/src/domains/meta-item-read-gate-parity.test.ts, 77 cases. It drives the same fixtures throughdispatch()and throughRestServer. For each caller (holder, non-holder, anonymous) it asserts equalstatus+codeand 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 answersRestServergives, 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 typecheckandpnpm --filter @objectstack/runtime typecheck: exit 0. Both test layers are OK, with the runtime debt ledger unchanged.execctx-consumer-censuscounts 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 shelltraprestore, and each restore was proven by blob equalsHEADand an emptygit diff HEAD. The subject resolves fromsrc:../http-dispatcher.js, and@objectstack/restis aliased tosrcin the runtime vitest config, so nodistleg applies.gateMetaItemDocumentserves 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/publishedrow stays green because it is the mask's./published'sif (publishedMasker)disabled): 1 red,GET /meta/object/invoice/published × non-holder.Gates.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 70 commands for these 9 paths. All 70 exited 0 at0fcb064a2, and--ranreconciled 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.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-authoringfirst 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, andscripts/doc-authoring-prose-id.baseline.jsonshrinks by that one pinned pair (--census-ledger, shrink only).Patch round 1 (head
acb99baa9)Contract review
5856229893answered FAIL on0fcb064a2, and the seat's REWORK is5856245736. This round makes three changes and mergesorigin/main(6d38c526f, thenacb99baa9).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 stampedverifiedAt: 2026-09-27.app.jsonnavigation.requiredPermissionsandnavigation.requiresServicenow point atmeta-item-read-gate.ts#filterNav.book.jsonnamenow points atmeta-item-read-gate.ts#deriveImplicitPackageBook(thebookNamedlookup).doc.jsonnamenow points atmeta-item-read-gate.ts#resolveDocAudiences(docReader:audiences.get(docName)).orderandgrouppoint atmeta-item-read-gate.ts#docCorpusOf, the list-branch corpus projection.doc.jsondescriptionandtagspoint atrest-server.ts#readableTree. The tree route's{ name, label, description, order, group, tags, packageId }projection stayed inrest-server.ts, and the new file does not namedescriptionat all: the key-mention check refused that first repoint. So the pointer names the site that reads the key.tagsalso points atmeta-item-read-gate.ts#resolveBookTree.rest-server.tssatisfied the anchor):app.jsonrequiredPermissions,_unpublishedand the file's_notenow point atmeta-item-read-gate.ts#filterAppForUserWithReason;book.jsonaudiencenow points atmeta-item-read-gate.ts#audienceAllows(admitsBook);dashboard.jsonwidgets.requiresServicenow points atmeta-item-read-gate.ts#filterDashboardForUser.docs/adr/0056-permission-model-landing-verification.mdcitesrest-server.ts#filterAppForUser. That is still a live declaration (the delegate REST's list route calls), and the file is a governed surface.pnpm --filter @objectstack/spec run check:livenessexit 0 (「758 pointer(s) writtenpath#symbol, 758 naming a symbol the cited file contains」).scripts/liveness/check-liveness.test.ts: 64 passed, where 21 were red in CI. Allscripts/liveness/tests: 252 passed (local project) and 81 passed (repo project).Clause-②: yes.@objectstack/rest's only export subpath gainscreateMetaItemReadGateand five types:MetaItemReadGateSources,MetaItemReadVerdict,MetaItemReadRefusal,MetaReadGateCallerandMetaReadGatePolicy.@objectstack/restcannot import the runtime.?version=onGET /packages/:idis refused with404where the landed precedent for that exact condition on that exact route is400 VALIDATION_ERROR— and the one module that owns the rule is unreachable from the dispatcher package #17672 / 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.node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0.node scripts/check-changeset-no-major.mjs --base origin/main: exit 0.packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts, 7 cases. It boots a realLiteKerneland the realcreateHonoApp, and drives them withapp.request(...)./publishedand the set-gated book answer403, witherror.codePERMISSION_DENIED,success: falseand no secret on the wire. Thecrmapp answers200, pruned to[nav_leads].packages/adapters/hono: that package'svitest.config.tsaliases@objectstack/runtimeto a stub (src/__mocks__/runtime.ts) for every test.createHonoAppimportsHttpDispatcherfrom that specifier, so a host composed there would compose the stub.http-conformanceis where the repo already boots the two for real (hono-dispatcher-result-response.conformance.test.ts):@objectstack/honois aliased to source, and the runtime resolves throughdist/, a ledgered pair incheck-test-source-alias.@objectstack/http-conformanceis inturbo ls --affectedfor this diff, because it depends on@objectstack/runtimeand@objectstack/hono.Test Core's PR shards therefore collect it; they exclude only@objectstack/dogfood.dist/). The mutate leg replaced the gate callverdict = await judge(document);with a serve-as-stored verdict carrying the markerablated20193, then rebuilt the runtime.ablation-dist-preflightfound the marker indist/index.jsanddist/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 equalsHEAD,git diff HEADempty), rebuilt, and--absentfound no marker with a clean tree. Result: 7/7. Both legs ran atb58d036edand again atacb99baa9.Verification at
acb99baa9:@objectstack/hono: 5 files, 122 passed;@objectstack/http-conformance: 7 files, 96 passed;pnpm lint: exit 0;node scripts/check-issue-citations.mjs --base origin/main: exit 0;dispatch-gates --commandsderived 77 commands, all ran, and--ranreconciled 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 (createMetaItemReadGateand five types), the same bump the changeset forrepeatedQueryParamMessage's publication took. REST's own behaviour is unchanged.Clause-②: yes:@objectstack/rest's published export surface widens. Its only export subpath gainscreateMetaItemReadGateand 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'srequiredPermissionsrow). 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
Same family, outside this card's claimed surface: the dispatcher's
/meta/:typeLIST read is ungated too. Measured throughdispatch()for the same non-holder at head:GET /meta/doc?include=content→ 200,crm_admin_runbookwith its body;GET /meta/book→ 200,admin_guidewith its description;GET /meta/app→ 200,payroll(app-levelrequiredPermissions) andcrmwithnav_finance_ledger.RestServer's list route answers[crm_intro],[]and[crm]pruned. Fixing it needsRestServer's LIST filters extracted the same way, which is a second seam and not mechanical, so it was filed separately 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 rather than folded in here. [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 remains open.The before-table was taken through
dispatch(). Through a realcreateHonoApphost, the four rows are now a committed pin (hono-meta-item-read-gate.conformance.test.ts). Its ablation leg, with the gate call removed and the runtime rebuilt, is the Hono-level before reading: the four non-holder rows go red, and the holder rows stay green.A host whose
protocolhas nogetMetaItemsnow answers a doc or book item read with the fault (fail closed, ADR-0049) instead of the ungated body.RestServerhas the same requirement.Generated by Claude Code