Skip to content

fix(runtime,rest): the dispatcher /meta list prunes what RestServer's list prunes, through one list gate (#20237) - #20319

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20237-dispatcher-meta-list-gate
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20237-dispatcher-meta-list-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #20193's item half took in PR #20236.

#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 #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 #20193 lessons

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 trys, 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

… 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>
… 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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime, touching 14 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/api/environment-routing.mdx (via getItems (sdk, the bare tail of client method meta.getItems, bound to GET /api/v1/meta/:type; the bare tail of client method meta.getItems, bound to GET /meta/:type), meta.getItems (sdk, the route ledger binds it to GET /api/v1/meta/:type, selected by route anchor /meta/:type; the route ledger binds it to GET /meta/:type))
  • content/docs/api/error-catalog.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/api/metadata-api.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/api/plugin-endpoints.mdx (via getItems (sdk, the bare tail of client method meta.getItems, bound to GET /api/v1/meta/:type; the bare tail of client method meta.getItems, bound to GET /meta/:type), meta.getItems (sdk, the route ledger binds it to GET /api/v1/meta/:type, selected by route anchor /meta/:type; the route ledger binds it to GET /meta/:type), /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/api/wire-format.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/data-modeling/external-datasources.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/deployment/cli.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/kernel/contracts/metadata-service.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/permissions/system-context.mdx (via handleMetadataRequest (symbol, a top-level function))
  • content/docs/plugins/adding-a-metadata-type.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/protocol/diagram.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/metadata-service.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/ui/apps.mdx (via filterAppForUser (symbol, a method of class RestServer), /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class), /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/releases/v12.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v14.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class), /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-1.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-2.mdx (via /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-3.mdx (via RestServer (symbol, a top-level class), /meta/:type (route, a path literal in a comment in handleMetadataRequest; a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 33 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 6ac33a57dfa5e788fca8d032af72dd8b9d158d35 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fa63e1056a702db9e7ccffc8306a7c0fa919fbd3 — the merge of head a48f373a291779626fff06ab247c279f5719ff29 into base 6ac33a57dfa5e788fca8d032af72dd8b9d158d35, 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 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

⚠️ 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 6ac33a57dfa5e788fca8d032af72dd8b9d158d35 → 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: a48f373a291779626fff06ab247c279f5719ff29

① Derived judgments

  • The move is byte-faithful. Correct. I diffed the four removed rest-server.ts list blocks against createMetaListReadGate (the new tail of packages/rest/src/meta-item-read-gate.ts).
    • App arm:
      • the same ctx?.userId guard;
      • the same resolveRegisteredServices(serviceProbe(ctx), items), where sources.serviceProbe is serviceProbeFor(caller.__kernel), the deleted delegate's body;
      • the same once-per-list resolveNavServability/resolveNavDocAudience, over a superset of the ports the delegates built.
    • Dashboard arm: it still runs for an anonymous caller and fails open on a null probe, as before.
    • Book and doc arms: resolveDocsAudience(sources, …) and fetchAudienceBooks(sources) are exactly what RestServer.resolveDocsAudience and fetchAudienceBooks delegated to.
    • Reading visible instead of items is equivalent for app: the only earlier mutation is the api-only served-set face.
    • Order: the gate sits where the app filter sat, before ?id= (app-only) and before the locale collapse, slim and mask. Dashboard, book and doc moved ahead of ?id=/?object=, which are type-disjoint, so every set is unchanged.
    • Rewrap: the judged !== list rewrap is wire-identical to the old unconditional rewrap.
    • Unchanged: ?preview=draft, anonymous and fault handling (rethrowAuthzStoreUnavailable via metaReadAudienceSources.resolveCaller).
    • The rest.test.ts list-route tests are untouched: it() 215→215, expect( 490→490.
  • The 83 retargeted sites. Correct.
    • The removed rest.DELEGATE( occurrences across the three files are 75 filterAppForUser + 4 filterDashboardForUser + 3 resolveRegisteredServices + 1 resolveNavServability = 83, with zero leftovers on the branch.
    • it() 215/16/18 and expect( 490/25/42 are unchanged.
    • Each retarget reproduces the deleted one-liner: resolveRegisteredServices(rest.serviceProbeFor(kernel), …), and gateOf → resolveNavServability({...rest.metaListSource(p), navPruneLogged: rest.navPruneLogged}).
    • The delegates carried no guard of their own, so no path weakened.
  • The dispatcher rows. Correct.
    • Every exit now passes through gateMetaListAnswer (packages/runtime/src/domains/meta.ts): the gate, then maskObjectSchemaList, outside the lookups' swallowing trys. The exits are protocol, MetadataService.list and the ObjectQL registry (getAllObjects/listItems).
    • The registry objects exit hands the raw typeOrName to the mask. maskObjectSchemaList folds it via pluralToSingular, so it stays masked.
    • The parity pin (meta-list-read-gate-parity.test.ts, 40 cases) holds status, code and item shape equal for holder and non-holder, over doc?include=content, doc, docs, book, books, app, apps, dashboard, object, objects and view, plus the two fallback exits.
    • The composed-host pin (hono-meta-list-read-gate.conformance.test.ts, 6 cases) drives the card's three rows through the real createHonoApp.
    • Anonymous: the dispatcher answers 401 before any read, on both sides of the fix (see ③ b).
  • Deviation 4 is the declared fail-closed posture, not a regression.
  • No second resolver. Correct. The runtime's metaItemReadGateSources supplies only resolveCaller (the dispatch-resolved executionContext), resolveSecurityService, listMetaItems, serviceProbe and navPruneLogged. gateMetaListAnswer unwraps, rewraps and maps faults; it decides nothing.
  • Deviation 2 is an acceptable wart.
    • export { filterAppForUser } from './meta-item-read-gate.js' sits at rest-server.ts:189. index.ts names its exports one by one (no export *), and the package has only the . subpath, so the re-export is not public.
    • ADR-0056 row 18 (docs/adr/0056-…md:70) is Tier H. The PR body and the os-dev report note it for the maintainer.
    • Recommendation: the seat carries the re-anchor as a row in its next Tier-H batch, so the bridge does not rot. Not a required change.
  • The census arithmetic is right.
    • this.resolveExecCtx( 68→66, mentions 92→90, same-line .catch( 15→13. So CAUGHT goes 23→21, with 8 continuation sites and 45 bare sites unchanged.
    • The two removed sites are exactly the app and dashboard blocks' same-line caught calls.
    • The inline floor >=12 sits against a population of 13.

② Semver level

Clause-②: yes, @objectstack/rest minor, @objectstack/runtime patch, with no arm.

  • The one new value export, createMetaListReadGate, exists on the barrel (packages/rest/src/index.ts:89). The changeset (.changeset/20237-dispatcher-meta-list-gate.md), the PR body and claim 5859027544 agree.
  • Deviation 4 narrows no accept set. The requests accepted are unchanged; only the outputs move. They are either pruned to the declared contract (ADR-0046 §6.7, apps.mdx), or a fault where a gate input could not be read, which is the same posture fix(runtime,rest): the dispatcher /meta item reads ask the per-caller read gate RestServer asks (#20193) #20236 landed as patch.
  • So (narrowing) + BREAKING is not owed.

③ Boundary flags

  • Surface: 11 files, 1086/352 lines, all inside the claim's file surface: the gate module, rest-server.ts, index.ts, runtime meta.ts, the rest and runtime tests, one http-conformance pin, the census test and one changeset. No governed path, and no packages/spec or metadata-core edit.
  • PR body vs diff: every checkable claim holds: the 83 sites, the it() counts, the census numbers, the mask per transport, the three exits gated, and the re-export off the barrel. Fixes #20237 is the only closing keyword.
  • CI on a48f373a2: 32 check runs: 23 success, 3 expected skips (Build Docs, Console Pin Gate, Packed-tarball smoke), 6 in progress (Lint & Repo Gates; Test Core 2/4/5; Type Check workspace and consumer gates), 0 failed. The required set is not yet green, and nothing is red.
  • Out-of-scope (a): confirmed at source.
  • Out-of-scope (b): confirmed at source.
    • handleMetadataRequest opens with shouldDenyAnonymous({userId, isSystem}), with no path or method, which returns true for any unauthenticated caller (packages/core/src/security/anonymous-deny.ts:152).
    • RestServer's umbrella gate grants anonymous callers reachability of book and doc GETs via isPublicAudienceRead (rest-server.ts:2752), and §6.7 admits public only.
    • So the dispatcher is stricter: it withholds and exposes nothing. Not this PR's change.

Implemented-by: claude/issue-20237-dispatcher-meta-list-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: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 21:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit cc40033 Sep 27, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20237-dispatcher-meta-list-gate branch September 27, 2026 21:33
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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