Repository navigation
fix(runtime,rest): the dispatcher /meta list prunes what RestServer's list prunes, through one list gate (#20237) - #20319
Conversation
… list prunes, through one list gate RestServer's GET /meta/:type per-caller filters (the app nav filter, the dashboard widget gate, the book and doc audience prunes) move into createMetaListReadGate beside createMetaItemReadGate, and both the RestServer list route and the runtime dispatcher's one-segment list branch call it. Every dispatcher list exit (protocol, MetadataService fallback, registry fallbacks) now runs the gate and the ADR-0106 object mask, outside the lookups' swallowing try blocks. RestServer delegates left without a caller are deleted; their unit tests call the one implementation. filterAppForUser stays resolvable at rest-server.ts as a named re-export for ADR-0056 row 18's pointer. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…s, through dispatch() and a composed createHonoApp host meta-list-read-gate-parity.test.ts drives the same fixtures through both transports for a holder and a non-holder (doc with and without bodies, book, app, dashboard, object, view, and the plural spellings), plus the anonymous control, a gate-input fault, and the MetadataService and ObjectQL registry fallback exits. hono-meta-list-read-gate.conformance.test.ts pins the card's three rows through a real LiteKernel behind the real createHonoApp, with a holder as the control. The exec-ctx census moves 68 to 66 sites and 92 to 90 mentions: two same-line caught sites went into the shared list gate. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…spatcher-meta-list-gate
… delegates' any did The three resolveRegisteredServices reads take a non-null assertion (the delegate answered any), gateOf returns Promise<any> as its delegate call did, and one orphaned `rest` local goes. Type-level only; assertions unchanged. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 33 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 fa63e1056a702db9e7ccffc8306a7c0fa919fbd3 && git checkout fa63e1056a702db9e7ccffc8306a7c0fa919fbd3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6ac33a57dfa5e788fca8d032af72dd8b9d158d35 a48f373a291779626fff06ab247c279f5719ff29 && git checkout -B drift-repro 6ac33a57dfa5e788fca8d032af72dd8b9d158d35 && git merge --no-ff a48f373a291779626fff06ab247c279f5719ff29
node scripts/docs-audit/affected-docs.mjs --json 6ac33a57dfa5e788fca8d032af72dd8b9d158d35
|
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: PASS |
…bled (ADR-0049) (objectstack-ai#20343) Fixes objectstack-ai#20295 Clause-②: no (narrowing) ## Summary Retires two keys of `RestServerConfig.api` (`RestApiConfigSchema`) under ADR-0049 enforce-or-remove — triage's grade, verbatim: 「Verdict: **RETIRE** the 4 keys, by the maintainer's criterion」. - `api.responseFormat` — the whole block (`envelope`, `includeMetadata`, `includePagination`): one `retiredKey()` tombstone for the container. - `api.documentation.enabled` — a tombstone inside the still-live `documentation` block. Its other members (`title`, `description`, `version`, `termsOfService`, `contact`, `license`) are untouched: they belong to the sibling card objectstack-ai#20294. Both keys were parsed, defaulted and copied into `RestServer`'s config by `normalizeConfig`, and nothing read them back. `envelope: false` unwrapped no response. `documentation.enabled: false` turned no document off, because `api.enableOpenApi` decides that mount. Now each key carries its prescription at the schema, `RestServer` construction refuses it (so does the REST plugin's `start`), and `normalizeConfig` neither forwards nor re-defaults it. ## Accept / refuse changes — every one pinned | Door | Input | Before | After | Pin | |:--|:--|:--|:--|:--| | `RestApiConfigSchema` | `responseFormat: {…}` — `{ envelope: false }`, the old defaults, `{ includePagination: false }`, `{}` | accepted, inner defaults filled | refused: `invalid_type` at `['responseFormat']`, message = prescription below | `packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts` | | `RestServerConfigSchema` | `api.responseFormat` | accepted | refused at `api.responseFormat`, same prescription | same file | | `RestApiConfigSchema` | `documentation.enabled: false` / `true` (the old default) | accepted (default `true`) | refused: `invalid_type` at `['documentation', 'enabled']`, prescription below | same file | | `RestServerConfigSchema` | `api.documentation.enabled` | accepted | refused at `api.documentation.enabled` | same file | | `new RestServer(…)` and `createRestApiPlugin(…).start` | either key | constructed; the key was ignored | throws; the message names `api.responseFormat` / `api.documentation.enabled`, `RestApiConfigSchema` and the prescription | `packages/rest/src/rest-api-config-dead-keys-refused.test.ts` | | `RestApiConfigSchema` | `documentation: {}` | parsed to `{ enabled: true, title }` | parsed to `{ title }` | spec retirement test (control), `rest-server.test.ts`, `rest-config-parse-not-cast.test.ts` §D | | TypeScript `RestApiConfig` (input) | either key | object / boolean | `never` (a tsc error at the authoring site) | two `@ts-expect-error` pins, held by `check:test-typecheck` | Unchanged, pinned as controls: every live key of the `api` block parses as before, including `documentation`'s other members; a config without the two keys mounts the same route set (`mounted({ documentation: { title, description } })` equals `mounted({})`); `enableOpenApi: false` removes exactly `GET /api/v1/docs` and `GET /api/v1/openapi.json`. ### Refusal texts (verbatim; both are also the new `.describe()` text, prefixed `[REMOVED] `) > `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. > `api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: whether the server publishes its OpenAPI document is decided by the sibling `api.enableOpenApi` at the mount, so `enabled: false` turned nothing off. Delete the key; `api.enableOpenApi: false` is the switch that leaves the `/openapi.json` document and its `/docs` viewer unmounted. Construction refusal (unchanged envelope, new lines): ``REST API configuration is invalid: `api` does not satisfy `RestApiConfigSchema` (@objectstack/spec/api), the schema that declares it.`` followed by ` - api.responseFormat: PRESCRIPTION` (or `api.documentation.enabled`). Describes removed with the keys: "Response format options", "Wrap responses in standard envelope", "Include response metadata (timestamp, requestId)", "Include pagination info in list responses", "Enable API documentation". No `os migrate meta` sentence in either prescription, on purpose: there is no D2 conversion for the command to list (see below), matching the sibling `crud.*` / `metadata.*` / `batch.*` tombstones on this same file. ## Premises measured before editing (origin/main 4e0f72e) 1. **Nothing reads the four keys.** `packages/**` non-test code: 0 reads — the only code sites were `NormalizedRestServerConfig`'s type and `normalizeConfig`'s own write. Lit control on the same instrument: `enableOpenApi` finds its read at `registerRoutes`. A spread / whole-block-destructure sweep over `packages/rest/src` (non-test) returns only the parse call and `const { enableProjectScoping, projectResolution } = this.config.api`. objectui at its pin `f8a9d0fb`: 0 for `RestApiConfig|RestServerConfig`, `responseFormat`, `includePagination`, `enableOpenApi` (control `basePath` = 184). cloud at `96eb092`: 0 authoring sites for the same terms and `documentation.enabled` (control `createRestApiPlugin` = 11; every call forwards the stack's own top-level `api:` block, whose schema carries neither key). 2. **Producers.** None outside the kit: the in-repo authors were four spec test cases, two rest test cases and the `@example` in the schema docblock; all are converted (the example now shows `enableOpenApi`). No example app, skill, form, i18n bundle or hand-written doc authors either key. 3. **Route.** `RestApiConfigSchema` and its inline `documentation` object are non-strict `z.object()`s ⇒ `retiredKey()` tombstones (a bare deletion would strip the key in silence, ADR-0104). Ledger rows stay `dead` with a REMOVED note; `responseFormat`'s three child rows collapse into its one row, because the tombstone is a leaf and child rows would report ORPHAN (the `crud.patterns` precedent). 4. **`normalizeConfig`** no longer lists `responseFormat`; `documentation` still passes through, so the retired `enabled` is neither forwarded nor re-defaulted (pinned). ## Choices settled here (four axes) - **`responseFormat` retired as one container tombstone, not three member tombstones.** Business need: no author and no reader of any member. Long-term: a container whose every member is retired would keep accepting `responseFormat: {}` — an empty knob a reader takes for a capability. AI-safety: one refusal on the key an author actually types; `{}` is refused too. Scope: one tombstone, one ledger row (the `crud.patterns` precedent, which also collapsed child rows). - **The server REFUSES (the `crud.patterns` posture), it does not `.omit()` and ignore (the `requireAuth` posture).** No boot path or shipped config writes either key, so nothing chose warn-and-ignore for them; a silent `.omit()` would recreate the strip this retirement removes. Ablation C below shows the difference is measurable. - **A tree-scoped absence pin was added** (the retirement playbook's default sweep), over the radius `@objectstack/spec` already declares in `scripts/cross-package-test-inputs.mjs`; no new declaration was needed (`check:cross-package-test-inputs` green). Its matcher is structural so that the agent alias map's `responseFormat: 'structuredOutput'` and an OpenAI-style `responseFormat: { type }` never match. ## The retirement kit - **Schema** `packages/spec/src/api/rest-server.zod.ts` — two tombstones with in-schema comments; docblock example converted. - **REST server** `packages/rest/src/rest-server.ts` — only the `NormalizedRestServerConfig.api` type (near the old `:1105`) and the `parseDeclaredApiConfig` / `normalizeConfig` region. None of the regions objectstack-ai#20319 edited were touched; this branch was rebuilt on main after objectstack-ai#20319 landed. - **ADR-0087** — `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat` and `api/RestApiConfig:documentation.enabled` (one entry file each); one D3 entry for the family, `rest-api-config-dead-keys-retired` (ruling B on objectstack-ai#17152); no D2 conversion, because a `RestServerConfig` is plugin TS configuration, never a stack collection member or a stored row. The generated regions of `registry.ts` are regenerated. - **Ledger** `packages/spec/liveness/rest_api.json` (both rows REMOVED, `cross-repo` scope, `_note` addendum), `liveness/README.md` row, generated `state-counts.md` (`rest_api` 14 → 12 dead, 26 → 24 classified). - **Generated** `authorable-surface/api.json` (`responseFormat` → `[RETIRED]`), `content/docs/references/api/rest-server.mdx`, `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md` (`api/` 432 → 431 sites: the inline `responseFormat` object left). - **Tests** — the two new pin files above (the spec one runs in the `repo` project); converted cases in `rest-server.test.ts`, `rest-config-parse-not-cast.test.ts` §D and one comment in `rest-api-config-defaults-follow-spec.pin.test.ts`. - **Changeset** `.changeset/20295-rest-api-config-dead-keys-retired.md` — `@objectstack/spec` and `@objectstack/rest` `minor`, BREAKING banner, FROM → TO with the one-line fix, and the ADR-0087 disposition `registered rest-api-config-dead-keys-retired`. ## Verification — at HEAD `6f07f0c1` All heavy runs went through `scripts/pm/os-verify-lock.sh`; every exit code below was captured to disk before any pipe. `6f07f0c1` is `origin/main` `a78f731a` merged in (after objectstack-ai#20319 landed) through `scripts/pm/os-regen-merge.sh`, then the rest dependency closure rebuilt. - `pnpm --filter @objectstack/rest exec vitest run --project local` — exit 0, 204 files, 3680 passed, 1 skipped. - `pnpm --filter @objectstack/spec exec vitest run --project local` — exit 0, 554 files, 16353 passed, 1 todo. - `pnpm --filter @objectstack/spec exec vitest run --project repo` — exit 0, 34 files, 620 passed (the new tree-scoped pin runs here). - `pnpm --filter @objectstack/spec run typecheck` and `pnpm --filter @objectstack/rest run typecheck` — both exit 0 (`tsc --noEmit` plus `check:test-typecheck`; the spec one also `check:scripts-typecheck`). `check:test-typecheck` green is what proves the two `@ts-expect-error` pins bite: an unused one would be a new TS2578 signature. - `pnpm --filter @objectstack/spec run check:generated` — exit 0, all 15 generated artifacts current at this head. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 115 commands for this diff at this head; `--ran` reconciliation: **115 accounted — 113 run, all exit 0; 2 NOT MEASURED; 0 unrun.** - NOT MEASURED `pnpm check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: it reads every package's `dist/`, and a whole-tree build is outside this card's local scope (CI's Build Core builds it). - NOT MEASURED `pnpm check:type-check-debt` — its `--re-measure` rebuilds every package and re-runs tsc over every DEBT / EXEMPT entry; this diff touches no DEBT or EXEMPT package (CI's Lint job). `pnpm check:type-check-coverage` ran, exit 0. - Consumer fixture triage: the tree-scoped absence pin is the sweep — it walks `packages`, `examples`, `skills`, `content` and `scripts` and finds no `api` block authoring either key anywhere (anti-vacuity: more than 1000 files visited, more than 5 of them spell `responseFormat`). The near consumers that parse or type this config were also run: `@objectstack/core` `src/qa/http-adapter.test.ts` (it parses `RestApiConfigSchema.parse({})` for the route prefix) — exit 0, 32 passed; `@objectstack/client` `client.data-prefix.test.ts` + `client.metadata-prefix.test.ts` (they type a `RestServerConfig`) — exit 0, 13 passed. The rest of the downstream closure (`pnpm --filter '...^@objectstack/rest'`: cli, runtime, hono, the examples, …) was NOT rebuilt or run locally, by the retirement playbook's rule that the absence pin, not a consumer-closure rebuild, is the sweep; CI runs them. ### Reverse verification (one-time; each mutation through `scripts/ablation-replace.mjs`, restored and proven by blob == HEAD and an empty `git diff HEAD`) Run at `2e575f15` (this branch before the second merge of main; the merge touched none of the three mutated files). Every direction observed is the expected one: red. - **A — the schema tombstone.** `responseFormat: retiredKey(` → `responseFormat: z.any().optional() ?? retiredKey(` in `rest-server.zod.ts` (anchor 1 → 0, blob `32ebfe55` → `bfbb9755`). Spec retirement test: **6 failed | 10 passed** — the four `responseFormat` door pins, the whole-config door, and the tsc pin's parse leg. Restored (blob == HEAD); control rerun 16/16. - **B — the tree-scoped pin.** First attempt was a NO-OP and is not counted: its replacement contained the anchor, so `ablation-replace` refused (anchor 1 → 1), restored, and never ran the test. Redone with a disjoint spelling: `responseFormat: { envelope: false } as never,` planted before `enableDiscovery: true,` in `packages/spec/src/api/rest-server.test.ts` (anchor 1 → 0, blob `794c1326` → `0766f4e0`). **1 failed | 15 passed** — the absence pin, naming `packages/spec/src/api/rest-server.test.ts:644`. Restored (blob == HEAD). - **C — the server's parse.** `RestApiConfigSchema.omit({ requireAuth: true })` → `.omit({ requireAuth: true, responseFormat: true })` in `rest-server.ts` — the silent-strip posture (anchor 1 → 0, blob `abf25fa0` → `e93b894a`). Rest refusal test: **3 failed | 5 passed** — the `responseFormat` refusal, its no-`api.version` positive control and the plugin path; the `documentation.enabled` pins stay green, as they must. Restored (blob == HEAD); control rerun 8/8. After all four legs: `git diff HEAD` empty, each blob equal to its HEAD blob. The rest test's subject is `./rest-server.ts` (source, no alias hop), and the spec test's subject is `./rest-server.zod.ts` (source), so no `dist/` preflight applies. ## Acceptance notes - **`packages/rest/CHANGELOG.md`** is in the card's file surface but is release-owned (AGENTS.md Documentation Guardrails): not edited; the changeset is its input. - **`.changeset/14640-rest-api-liveness-ledger.md`** (pending, another card's) says `documentation` and `responseFormat` "are accepted, validated and normalized, and change nothing" — true when it landed; not edited here. The release compiles it next to this changeset. - **Clause-② value.** The claim carried `Clause-②: yes`, from triage's execution note. Measured on this diff: no accept set widens and no export is added; the only additions are ADR-0087 ledger registrations, which the two nearest retirements (objectstack-ai#20227, objectstack-ai#20238) declared under `no`. On that measurement the seat answered `no (narrowing)`, and this body and the changeset carry it. The gate readings are unchanged (`minor`, declared breaking, `registered`). - **Observed, not filed (dormant):** `RestApiPluginConfigSchema.responseEnvelope` in `packages/spec/src/api/plugin-rest-api.zod.ts` is a second declared envelope toggle. The spec schema has no runtime parser (`packages/rest` declares its own `RestApiPluginConfig` interface) and is not enrolled in any liveness ledger. No reach was measured, so nothing is filed. Carrier: none. - No governed surface is touched (`docs/audits/**` is not on the register). --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20237
Clause-②: yes
The runtime dispatcher's
/meta/:typeLIST now prunes whatRestServer'sGET /meta/:typeprunes, for the same caller. It is one function with two callers.RestServer's per-caller list filters moved unchanged intocreateMetaListReadGate, beside the item gate inpackages/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 inpackages/runtime(ruling5793362670item 1). This is the route #20193's item half took in PR #20236.#20139 remains open.
The mechanism assumptions, measured first
1.
RestServer's LIST filters, classifiedIn handler order (
GET ${metaPath}/:type, aftergetMetaItems(listRequest)):apiserved-set face (selectServedEndpoints)appnav filter (filterAppForUser: unpublished,requiredPermissions, docs-audience entry arm, plus the ADR-0057 D10 and #7912 servability arms)app?id=filterdashboardwidget gate (ADR-0057 D10)view?object=filterbookaudience prune (ADR-0046 §6.7)doceffective-audience prune (ADR-0046 §6.7)doclocale collapse, translationdoccontent slim (?include=content)slimDocList), singular spelling onlyThe 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:getMetaItems, masked for objects and slimmed for docs;list, reached when the protocol does not answer the type. It was slimmed only: no gate, and no object mask;listItems. It had no gate, no mask and no slim; forobjectit answersgetAllObjects(), 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()(thecreateHonoAppcatch-all's delegate) at base2dccb7d4, against the same fixtures and caller asRestServer's list route. The caller is a member withoutcrm_admin:RestServer, same callerGET /meta/doc?include=contentcrm_intro,crm_admin_runbookwith its bodycrm_introcrm_introGET /meta/bookadmin_guide(with its description),help_centerhelp_centerhelp_centerGET /meta/appcrmwith 3 entries,payroll,launchpadcrm, pruned tonav_leadscrm, pruned tonav_leadsGET /meta/dashboard(any caller)ops, both widgetsops,w_open_casesops,w_open_casesGET /meta/objectinvoice, masked toamountGET /meta/docs?include=content,/meta/books,/meta/appsRestServerbefore and after.401 UNAUTHENTICATEDon every list, before any read, both before and after. FordocandbooklistsRestServerinstead serves thepublicentries (none in this fixture). The dispatcher is stricter there; see Acceptance notes.4. The #20193 lessons
pnpm --filter @objectstack/spec run check:liveness: exit 0, 760 of 760path#symbolpointers resolve, and no ledger file was edited.packages/qa/http-conformance/src/.createMetaListReadGate. It takes the item gate's existingMetaItemReadGateSources, so no new type is exported. That keepsClause-②: yesand makes@objectstack/restminor;@objectstack/runtimeispatch.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 isRestServer'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.gateMetaListAnswer, which runs the shared gate and then the ADR-0106 mask. The lookups keep their owntrys, 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, or500for a shapeless one) and never falls through to the next store. Each exit keeps its own projection (the doc slim) exactly as before.RestServerdelegates. Seven private delegates lost their only caller with the move (filterAppForUser,filterDashboardForUser,resolveRegisteredServices,resolveNavServability,resolveNavDocAudience,fetchAudienceBooks,docCorpusOf). They are deleted:noUnusedLocalsreports each one as TS6133, and a delegate with no caller is a second place to read a rule that nothing runs.rest.test.ts,meta-app-nav-servability-gate.test.tsandmeta-app-nav-doc-audience.test.tsreached those delegates throughany. They now call the one implementation directly. Assertions are unchanged, and theit()counts are unchanged at 215, 16 and 18.filterAppForUserstays resolvable atrest-server.tsas a named re-export, because row 18 ofdocs/adr/0056-permission-model-landing-verification.mdcitespackages/rest/src/rest-server.ts#filterAppForUser. Measured: deleting the declaration turnscheck:adr-symbol-anchorsunresolved-symbolon 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 throughdispatch()and throughRestServer'sGET /meta/:type:RestServergives the non-holder (11);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;401before any read;RestServer's anonymous doc and book lists serving no gated content; and a books-read fault answered as that fault;packages/qa/http-conformance/src/hono-meta-list-read-gate.conformance.test.ts, 6 cases, through a realLiteKernelbehind the realcreateHonoApp(app.request(...)). For the non-holder, the card's three rows: the doc list with bodies listscrm_introonly, and the gated doc's name and body are nowhere on the wire; the book list listshelp_centeronly; the app list listscrmpruned tonav_leads, withpayrollabsent. The holder control lists everything in full. Only identity is stubbed, and the runtime resolves throughdist/.Ablation (the fix committed first at
34f2588dd;scripts/ablation-replace.mjsin WRAP mode, plus a shell trap restoring fromHEAD):gateMetaListAnswer's gate callconst judged = await createMetaListReadGate(...)(list);was replaced by a pass-through carrying the code markerablated20237. The anchor count went 1 → 0 and the blobe5cbe6ba→74221a11. After a runtime rebuild,ablation-dist-preflightfound the marker indist/index.jsanddist/index.cjs.HEAD(e5cbe6ba) andgit diff HEADis empty. After a rebuild,--absentfound no marker in any of the 6 built files, and the whole-treegit status --porcelainwas empty. Result: 6 of 6 and 40 of 40.Verification at head
a48f373a2(after mergingorigin/mainat6a6a17b62, merge commit8c972a5c4):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 at8c972a5c4; 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 at8c972a5c4, for the same reason.typecheckfor rest (ata48f373a2), runtime and http-conformance (at8c972a5c4): 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 --commandsderived 61 commands for these 11 paths. All 61 exited 0, and--ranreconciled them: 61 derived, 61 run, 0 NOT-MEASURED (a derived zero from recorded exit codes).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/metalist.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'srequiredPermissionsrow: 「absent from the/metabody」). 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 whoserequiredPermissionsthey 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
/metalist is a second implementation ofRestServer's), measured throughdispatch()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=crmignores?id=: the holder is listed all three apps, whereRestServeranswers[crm];GET /meta/view?object=nopeignores?object=and lists every view, whereRestServeranswers[];GET /meta/docs, the plural spelling without?include=content, serves the doc bodies, becauseslimDocListcompares the raw segment with'doc'.RestServerslims both spellings;apiserved-set face are not run.publicdocs and books are unreachable on a catch-all-only host. The dispatcher answers401to every anonymous/metaread. With apublicbook claimingcrm_intro,RestServerlistspublic_guideandcrm_introto an anonymous caller, and the dispatcher answers401 UNAUTHENTICATED. That is fail-closed, so it is not this card; it is reported for the seat.rest-server.ts#filterAppForUser. The rule lives inmeta-item-read-gate.ts#filterAppForUser, andrest-server.tsre-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.getMetaItemsanswers a doc list with the fault rather than the unfiltered list, the same requirement the item gate andRestServeralready have.RestServerdelegates were deleted and their unit tests retargeted (thenoUnusedLocalsconsequence of the move);Generated by Claude Code