Skip to content

fix(runtime)!: the dispatcher's by-name /meta doors ask RestServer's query decision, so ?member= is honoured or refused, never dropped (#23072, dispatcher half) - #23211

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23072-s2-dispatcher-read-decision
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23072-s2-dispatcher-read-decision

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23072
Clause-②: yes (narrowing)

The dispatcher half of #23072, under the maintainer's ruling B (6138588561): one portable query decision in packages/rest/src/meta-item-read-gate.ts, called by both transports. Slice 1 (the RestServer half, PR #23133) landed at c4b84ad702. This slice closes the card.

The decision

  • metaItemQueryRefusal(door, query), new on @objectstack/rest's entry. door is 'item', 'layers', 'published' or 'save'. It answers { status: 400, code: 'VALIDATION_ERROR', message, rule }, or undefined to go on. The rules, in order:
    1. recognition (rule: 'unknown-parameter'): a name outside the door's closed set, with the sentence refuseUnknownQueryParams answers;
    2. arity ('repeated-parameter'): a single-valued name supplied twice, with the multiplicity rule's sentence. A name supplied once as a one-element array is unwrapped in place, as refuseRepeatedQueryParams unwraps it;
    3. item only, a member on the layered view ('layered-member'): RestServer's sentence.
  • metaMemberSelector(query), new on the entry: the selector's one reading, { member } or {}. It moved unchanged from rest-server.ts.
  • Entry surface: two values. No new type is exported from the entry: MetaItemQueryDoor and MetaItemQueryRefusal are module exports the emitted .d.ts declares locally beside the two functions (read in dist/index.d.ts and dist/index.d.cts).
  • What moved into meta-item-read-gate.ts, values unchanged, still exported from rest-server.ts by re-export (so rest-server-meta-member-selector.test.ts and rest-server-meta-door-closed-sets.test.ts import them unchanged): META_ITEM_MEMBER_PARAM, META_ITEM_READ_PARAMS, META_ITEM_SAVE_PARAMS, META_ITEM_LAYERS_PARAMS, META_ITEM_PUBLISHED_PARAMS and metaMemberSelector. They have to sit with the decision: rest-server.ts imports the gate module, so the gate module cannot import it back.
  • New and not exported: the per-door table; the dispatcher save's set, derived as META_ITEM_SAVE_PARAMS less force rather than kept by hand; and the layered-member sentence.
  • query-allowlist.ts: unknownQueryParamRefusalMessage(query, allowed) is the recognition sentence as a pure function. refuseUnknownQueryParams now writes it, so every route keeps its answer. The decision takes the arity halves (readSingleQueryValue, repeatedQueryParamMessage) from query-multiplicity.ts, whose index note names them as the portable halves.

RestServer's item GET, re-pointed in spelling only

  • The handler asks metaItemQueryRefusal('item', req.query) once, at its top. Rules 1 and 2 are written there, in the nested { error: { code, message } } their gates wrote. Rule 3 is thrown where it always was, after the protocol and the mask posture, to handleRouteError, which writes its flat body.
  • Byte identity, measured as a differential: one scratch harness (never committed) drove RestServer's two item GET routes, /api/v1/meta/:type/:name and the environment-scoped one. It sent 30 queries under 8 configurations (cache on and off; a protocol with and without a layered read, with and without a member refusal; a system caller and a plain caller). It recorded every status, body, header, thrown error, the query object after the call (the in-place unwrap) and every protocol call, on the base tree de24373ecb and on af356c3a13. Result: 480 records, 256 of them 400 and 224 200. Both dumps are 348398 bytes, cmp reports them identical, and both hash to sha256 e27c1b55dc34648f12861a8489e8d75516b384bc6c8b4b6e54de0b92f8177ca9.
  • The request-side pins: rest-server-meta-member-selector.test.ts is unchanged and green.

The runtime dispatcher, per branch

Each branch now asks the decision through deps.error(message, status, { code }), its own nested envelope.

Branch Read points (measured on handleMetadataRequest) Before After
item GET / HEAD state, preview (inline), package (metaItemPackageBinding), layers (wantsMetaItemLayers), locale (metaRequestLocale behind createMetaItemAnswer), member (metaMemberSelector, new) no set; member never read META_ITEM_READ_PARAMS and the five-name arity list, both RestServer's. Rules 1 and 2 are answered at the read's entry, after the 405 verb check. Rule 3 is answered after the mask posture, as on RestServer.
PUT package (metaItemPackageBinding), mode (metaSaveRequestOptions) no set; force and member dropped package, mode, both single-valued: META_ITEM_SAVE_PARAMS less force, which this door was ruled never to take (its 409 remedy already says so). Answered after the capability and walled-catalog gates, before any query read.
/layers package (metaItemPackageBinding) no set META_ITEM_LAYERS_PARAMS, package single-valued, first statement
/published none no set META_ITEM_PUBLISHED_PARAMS (empty), first statement

The member on the item read (ruling B's measured costs):

  • It reaches all three getMetaItem calls: the draft read (readPendingDraft), the object branch's readFromProtocol, and the generic branch.
  • The two bare catches let the protocol's refusal out when a member is named. The outer catch renders it through errorFromThrown, so a selector on a type with no members, or a blank one, is the protocol's located 400.
  • No name-keyed fallback answers a named member. The object branch goes protocol-first and skips the registry. The generic branch skips MetadataService.getItem. A member the bundle does not hold is 404, as on RestServer.
  • ?layers= beside ?member= is refused, whatever the protocol can do.
  • With no member named, every fall-through is unchanged (control pinned).

Caller census

  • @objectstack/client:
    • meta.getItem sends package, and meta.getPublished sends nothing. Both are accepted.
    • meta.saveItem sends package and mode (accepted), and force only when its caller passes force: true (refused on the dispatcher, see below).
    • The SDK has no /layers caller. No in-repo production code passes force to saveItem: the only callers that do are client.test.ts URL-builder tests over a mocked fetch.
  • objectui at the console pin 1fb1ced36243 (a blobless read-only fetch):
    • MetadataClient.readItemResponse sends state, preview and package, and layered sends package. Both are accepted.
    • save sends force only from ResourceEditPage and PermissionMatrixEditor's force-save, after a 409 DESTRUCTIVE_CHANGE and a confirm. On a dispatcher-only host that retry already failed with the same 409 (the dispatcher's remedy: "accepts no force"). It now answers the located 400 naming force. That is a changed failure, not a newly red flow.
    • The direct fetches with a query go to /_drafts and list reads, not these four doors.
  • In-repo tests: none sends a name outside the new sets, so no in-repo test caller needed fixing.
    • @objectstack/runtime local and repo projects: green.
    • @objectstack/http-conformance: hono-meta-item-read-gate, hono-meta-list-read-gate and hono-meta-save-preconditions, which boot createHonoApp with the real dispatcher (PUT ?mode=draft, GET ?state=draft). 3 files, 17 tests green.
    • @objectstack/hono: 6 files, 139 tests green.
  • Dogfood: the showcase serves these doors through RestServer, so dogfood exercises the re-pointed item GET. Seven files ran: email-template-two-locales-survive-boot (?member=, ?memeber=), flow-shipped-name-layered-read, flow-unshipped-name-layered-code, showcase-object-extension-meta-read and showcase-object-extension-scalar-divergence (?layers=true), multi-package-artifact (?package=), and meta-published-and-state-routes. 7 files, 47 tests green.

Tests

Everything below ran at head 6fe8e74629, through scripts/pm/os-verify-lock.sh, and each reported VERDICT command-exit 0.

Typecheck:

  • @objectstack/rest: tsc --noEmit plus check:test-typecheck OK (0 files / 0 errors).
  • @objectstack/runtime: check:test-typecheck OK (27 files / 190 errors / 68 pinned held, the ledger unchanged).
  • --listFiles shows each package's tsconfig.test.json compiles the new and edited test files: rest 1 of 1, runtime 2 of 2.

@objectstack/rest:

  • vitest --project local: Test Files 277 passed (277), Tests 5802 passed | 326 skipped (6128).
  • --project repo: Test Files 5 passed (5), Tests 213 passed | 2 skipped (215).

@objectstack/runtime:

  • vitest --project local: Test Files 365 passed (365), Tests 5201 passed | 19 skipped (5220).
  • --project repo (with meta-list-projection-parity): Test Files 3 passed (3), Tests 808 passed (808).

The census suites:

  • @objectstack/http-conformance (the three hono-meta-* files): 3 files, 17 tests passed.
  • @objectstack/hono: 6 files, 139 tests passed.
  • @objectstack/dogfood (the seven files above): 7 files, 47 tests passed.

New pins:

  • packages/rest/src/meta-item-query-decision.test.ts, 19 tests: each door's set and sentence, arity, the in-place unwrap, the order of the three rules, metaMemberSelector, and RestServer's item read writing exactly the decision, nested for rules 1 and 2 and flat for rule 3.
  • packages/runtime/src/domains/meta-item-query-decision.test.ts, 23 tests: the dispatcher's four doors, refusal and preservation for each. Each refusal is compared with RestServer's answer for the same request (code and message), on the item read, /layers and /published. The value pins run against a real ObjectStackProtocolImplementation over an engine never reached.
  • meta-list-projection-parity: ITEM_PROBES gains ?member=zh-CN and ?layers=true&member=zh-CN. The protocol double refuses a member on a non-bundle type, as the protocol's first step does. Both transports agree on all 26 item cells times 4 callers, plus the 2 undetermined object cells. The reference-moves block pins that ?member= moves RestServer's answer. That is 57 more parity tests (682 to 739).

Controls: rest-server-meta-member-selector.test.ts and rest-server-meta-door-closed-sets.test.ts are unchanged and green.

Ablations

Both legs ran on the committed head 6fe8e74629 (and before the merge on af356c3a13; meta.ts is the same blob 2fbc1e6b65f1 on both). They ran inside scripts/pm/os-verify-lock.sh through scripts/ablation-replace.mjs (wrap mode, anchors that must hit), under a bash EXIT / INT / TERM trap that restores git checkout HEAD -- on the absolute path and proves the restore by blob hash and an empty git diff HEAD. The tests import meta.ts from src/ (../http-dispatcher.js), so no dist/ preflight applies. Expected direction: red. Observed: red.

  • The decision dropped on all four dispatcher doors. Anchor metaItemQueryRefusal(, 4 hits, replaced by a function that answers undefined (marker __ABLATION_23072_DECISION_DROPPED__). The blob went 2fbc1e6b65f1 to a21ea5f22452.
    • Result: Tests 38 failed | 724 passed (762).
    • The 10 red pins in the new dispatcher file: every refusal on the item read (misspelled, repeated, member beside ?layers=), /layers (member, repeated package), /published and PUT (member, force, repeated mode), plus the one-element-array unwrap.
    • The 28 red parity cells: every ?layers=true&member=zh-CN item cell.
    • Restored to blob 2fbc1e6b65f1 == HEAD, git diff HEAD empty, marker count 0.
  • The member forward dropped from the generic branch's getMetaItem. Marker __ABLATION_23072_MEMBER_FORWARD_DROPPED__; the blob went 2fbc1e6b65f1 to df1a6d6c06e8.
    • Result: Tests 31 failed | 731 passed (762).
    • The 7 red pins: the honour pin, draft and preview, the absent member, the all-names control, the one-element array, the blank selector and the view refusal.
    • The 24 red parity cells: every generic-type ?member=zh-CN item cell. The object cells stay green, their forward intact.
    • Restored as above.

Gates

At head 6fe8e74629:

  • The derived list: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths. It took the change set from the merge base d4aaeaf0e2: 9 paths, +1073 / −110. It derived 65 commands, the PM's count. All 65 ran with the exit code captured before any pipe, and all exit 0.
  • One re-run: pnpm check:dual-build-cjs-loads first exited 3, PREREQUISITE NOT MET (8 packages without dist/: studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge). After building them (58 of 58 cached) it exits 0: "108 published require entry point(s) across 67 package(s) load".
  • Reconciliation: node scripts/pm/dispatch-gates.mjs --ran ran.list exits 0: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED".
  • Beyond the list:
    • pnpm check:route-ledger-census exits 0 ("all 1 census sentence(s) match their arrays"). pnpm check:route-envelope (in the 65) exits 0, and rest-server.ts gained no dialect.
    • check:dts-emitted ran in the rest build, and dist/index.d.ts / index.d.cts declare metaItemQueryRefusal and metaMemberSelector on the entry's export list.
  • The changeset gates, with the PR's title and body as the --event payload:
    • check-changeset-no-major exits 0: "LEVEL AXIS: this PR declares clause-② yes (narrowing) … no package … is graded patch".
    • check-adr-0087-registration exits 0: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)".
    • check-empty-changeset exits 0.
  • Lint, a proven narrowing (the whole-repo pnpm lint is CI's):
    1. Population, read from eslint.config.mjs: files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] less NEVER_LINTED. ESLint's isPathIgnored is false for all 8 touched .ts files.
    2. eslint --no-inline-config --format json over them: 8 files, errorCount 0, warningCount 0, fatal 0, exit 0.
    3. Invariance: calculateConfigForFile answers parserOptions.project null and projectService null for every one, so no type-aware rule runs, and this diff cannot move an untouched file's verdict.

Acceptance notes

  • Changeset:
  • Changeset gates:
    • check-changeset-no-major --event accepts every well-formed spelling for this diff, because both packages are minor: yes, yes (widening), yes (narrowing), no and no (narrowing).
    • no (widening) is malformed: its level axis is NOT MEASURED.
    • check-adr-0087-registration reads the changeset as BREAKING+bang+clause-②-narrowing with that disposition.
    • yes (narrowing) is the truthful spelling: the entry widens, and four runtime doors narrow.
  • Ordering on the dispatcher:
    • Rules 1 and 2 answer at each door's entry. On /layers that is before the 501 for a protocol with no layered read, and on PUT before the protocol is resolved.
    • RestServer's own /layers and PUT keep their gate lines, which this slice does not touch: /layers recognition is its first statement, and its arity check runs inside serveMetaItemLayered, after the 501.
    • So a request that is both malformed and aimed at a host with no layered read answers 400 on the dispatcher and 501 on RestServer. Through the createHonoApp catch-all no repeated name reaches the dispatcher at all (it flattens the URL query, last value wins).
  • The decision mutates its query (the one-element-array unwrap), as refuseRepeatedQueryParams always has. It is documented on the function, and every read after it is meant to see the string.
  • Line budget: +1073 / −110 = 1183 changed lines over 9 files, under the 3,000-line threshold.
    • Source: +583 (the gate module +247, of which about 100 are the moved declarations and their docblocks; meta.ts +117 / −11; rest-server.ts +51 / −90; query-allowlist.ts +27 / −8; the entry +9) and the changeset +23.
    • Tests: +599 / −1.
    • The ruling's estimate was 350 to 550 lines; the source half sits in it, and the tests and the moved docblocks are the rest.
  • Merged origin/main (d4aaeaf0e2, 5 commits: metadata-protocol's save door, runtime's packages.ts and flow-clone.ts, spec retirements; none touches a file in this diff). The build state was refreshed after it (install, closure build, packages/runtime/.objectstack removed).
  • Not a governed surface: no .claude/**, docs/adr/**, skills/**, AGENTS.md or CLAUDE.md path.

Generated by Claude Code

metaItemQueryRefusal (meta-item-read-gate.ts) judges the item read, /layers,
/published and the dispatcher's PUT: recognition over the door's closed set,
the arity of its single-valued names, and the item read's refusal of a member
on the layered view. RestServer's item GET is re-pointed at it with its
answers unchanged; the runtime dispatcher asks it on all four doors and its
item read honours ?member= through every protocol read, with the
name-keyed fallbacks never answering a named member.

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

The decision's sets, arity, rule order and in-place unwrap; RestServer's item
read writing exactly the decision; the dispatcher's item read honouring
?member= through every protocol read with the protocol's refusal handed on and
the name-keyed fallbacks never answering a named member; /layers, /published
and PUT refusing what they do not read. meta-list-projection-parity's
ITEM_PROBES gains the member selector on both transports.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…'s query decision

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

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

Coarse fallback — 35 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 150da61aa54c6a287bdc834db3b13399d18e50d9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 150da61aa54c6a287bdc834db3b13399d18e50d9

⚠️ 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 150da61aa54c6a287bdc834db3b13399d18e50d9 → 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: 6fe8e7462997a86173eedf17e0625062bdebfd76
Local-runs: none

Rendered 2026-10-13T09:21Z by the adopting seat's session, read-only, adversarially: the card thread (triage 6129664008, the serial notes, claim 6139487972, the maintainer's ruling B 6138588561, the slice-1 reports 6137337775 and 6139033003, the landed note 6139451070, the dev's report 6142050405, and the ACCEPT 6142115078 read as a claim to test); PR #23133's two records (6138381862 FAIL on ② and 6139157658 delta PASS) as the lane's precedent; PR #23211's body and file list; the net diff of refs/pull/23211/head against its merge base d4aaeaf0e2 (9 files, +1073 / -110, read whole); the head's files where the diff lacked context (domains/meta.ts, rest-server.ts, meta-item-read-gate.ts, query-allowlist.ts, query-multiplicity.ts, index.ts, http-dispatcher.ts, @objectstack/types' resolveThrownHttpError, the Hono adapter's catch-all); the three changeset gates' sources; origin/main's pre-mode state; and the head's check-runs, polled twice. Nothing was built, run, re-run or ablated. objectui is not attached to this session and is not an input; its console pin 1fb1ced36243 is unchanged between origin/main and this head, so record 6138381862 item 8's reading of that pinned tree is the measurement used here.

① Derived judgments

Every accept-set and public-surface change the diff implies, named right or wrong against the handlers' ACTUAL read points on both transports.

  1. One decision (ruling B) — right, with one residual named. metaItemQueryRefusal(door, query) in packages/rest/src/meta-item-read-gate.ts holds the per-door table (accepted and once), the recognition sentence (unknownQueryParamRefusalMessage, imported), the arity sentence (repeatedQueryParamMessage, imported) and the layered-member sentence, in the order recognition, arity, layered-member. domains/meta.ts carries no second spelling: its four doors call the decision and read nothing else from the query but package (metaItemPackageBinding), mode (metaSaveRequestOptions, whose only query read is http.query?.mode), state, preview, layers (wantsMetaItemLayers), locale (metaRequestLocale behind answerMetaItem) and member (metaMemberSelector). rest-server.ts's item GET carries none: its two gate lines and the hand-spelled arity list ['layers', 'state', 'preview', 'package', member] are gone, and the five constants it re-exports are declared once in the gate module. The residual: RestServer's PUT (:7124-:7125, arity ['force', 'package', 'mode']) and serveMetaItemLayered (:4026, arity ['package']) keep slice 1's own gate lines, so those two arity lists are spelled once more there than in the decision's once table. They are not this PR's lines, the claim re-points RestServer's item GET only, the ruling's re-point sentence names the item GET only, and the SETS (the thing that narrows) are one source. Recorded for the seat as a follow-up under the ruling's own rationale (a name added later should reach both transports): a one-line re-point each, or a fifth door that reads force, in a later slice. Not a defect at this head.
  2. The item read's closed set on BOTH transports = layers, state, preview, package, locale, member — right. Dispatcher read points, measured on handleMetadataRequest's two-segment branch at this head: state (:1611), preview (:1619), package (packageBinding(), now called after the decision), layers (:1660, and rule 3), locale (answerMetaItem :650 builds metaRequestLocale({ headers, query }), and readPendingDraft hands its query to the same chain), member (:1604). No name the handler reads is outside the set (no new false 400), and no name in the set goes unread (no silent drop). locale is accepted and deliberately outside the once list, as on RestServer: metaRequestLocale's typeof guard sends a repeated value to the default, pinned in the new rest file. member is accepted on the item read alone and refused by the other three doors, pinned per door.
  3. PUT on the dispatcher = package, mode — right, and derived. META_ITEM_SAVE_UNFORCED_PARAMS is META_ITEM_SAVE_PARAMS.filter(name !== 'force'), frozen, not hand-kept. The branch reads package through the binding and mode through metaSaveRequestOptions, and nothing else: the grep of :1377-:1520 for query finds exactly those two reads. force is read nowhere on this transport (:1429-:1449: the Two more faces of the DESTRUCTIVE_CHANGE 409 prescribe ?force=true on routes that never thread it — the compound-name PUT /meta/:type/:a/:b and the runtime dispatcher PUT /meta #11095 ruling, settled, with the 409 remedy saying the door accepts no force), so refusing it is the honest answer and dropping it was the defect. RestServer's own PUT keeps META_ITEM_SAVE_PARAMS with force, untouched.
  4. /layers = package, /published = empty — right on both transports. The dispatcher's /layers reads query?.package once (:1109) and hands answerMetaLayered no query; its /published branch (:1125-:1260) reads no query key at all. RestServer's sets are slice 1's, unchanged, and the dispatcher's doors read the same constants.
  5. RestServer's item GET is byte-identical — the claim holds on the diff, not only on the dev's differential. Rules 1 and 2: the old refuseUnknownQueryParams body is now unknownQueryParamRefusalMessage verbatim (same object check, same sorted unknown list, same unknownQueryParamMessage(unknown, sorted allowed)), and the old refuseRepeatedQueryParams loop is reproduced line for line in the decision (same object check, same iteration order over the same five names, readSingleQueryValue, the same repeatedQueryParamMessage(name, count), the same in-place record[name] = read.value). The handler writes them as res.status(400).json({ error: { code, message } }), the nested body both gates wrote. Rule 3: the predicate is the same wantsMetaItemLayers(query) && member !== undefined on the post-unwrap query, the sentence is the same template with META_ITEM_MEMBER_PARAM, it is thrown at the same position (after resolveProtocol, metaTypeSingular and the mask posture, before the layered branch) as an Error carrying the same code and status, to the same handleRouteError, which writes the same flat body. No header is written by any of the three rules; the Deprecation and Link headers come after rule 3, unchanged. Order relative to the anonymous deny (the auth middleware at :2374, before every handler), the per-caller read gate (inside the branches after rule 3) and the mask posture (between rules 2 and 3) is unchanged, because the diff's two hunks in this handler touch only the top gate lines and the rule-3 throw, and the lines between are not in the diff. memberSelector is read after the unwrap, as before. The new rest file pins nested for rules 1 and 2 and flat for rule 3, with the protocol never called; the member-selector and closed-sets files are not in the diff. The 480-record differential is consistent with all of this; it was not re-run and is not needed.
  6. The dispatcher item read with ?member= — right; a named member is never answered with the canonical item. The selector reaches all three getMetaItem calls: the draft read (readPendingDraft, ...item.memberSelector), the object branch (:1739) and the generic branch (:1795). With a member named: protocolFirst is true (:1725), the object branch's bare catch rethrows (:1755), the registry read is guarded by !memberNamed (:1761), a protocol without getMetaItem falls to the branch's own 404; the generic branch's catch rethrows (:1829) and MetadataService is never resolved (:1839), so an absent member is the 404 and a protocol refusal reaches the outer catch, whose errorFromThrown(e, 404) keeps a thrown status and a registered code (resolveThrownHttpError: declaredStatus wins over the fallback), pinned against a real ObjectStackProtocolImplementation (a view and an object answer the protocol's located 400; a blank selector is refused). ?layers= beside a member is rule 3, before any read. With NO member named: ...{} spreads nothing into any request, memberNamed is false so every guard reverts to its old expression, both catches swallow as before, and MetadataService is resolved as before (control pinned). The one behaviour a member-less request gains beside the two intended refusals is the arity rule's in-place unwrap: a name supplied once as a one-element array ({ state: ['draft'] }) now reads as the string, where it read as absent. That is RestServer's own behaviour since packages/rest 的其它 req.query.* 读取点同样把 string | string[] 当字符串用(#6307 的未扩大部分) #6877, the acceptance notes declare the mutation, and the published Hono host never builds an array. Right.
  7. Refusal ordering vs authorization — right, with one placement difference declared. On the dispatcher the anonymous deny (:937) and the type-level read and write refusals (:947-:970) precede every branch, so all four decisions answer after them: no caller gets a 400 where it got the 401 or the type-level 403. PUT's decision sits after the capability 403 and the walled-catalog 403 (:1340, :1365) and before metaSaveRequestOptions, the protocol resolve and the 501, with the capability gate pinned to answer first; RestServer's sits after its 501. /layers' decision is the branch's first statement, before the protocol, the mask and the 501; RestServer refuses recognition first and arity after its 501. /published is first statement on both. The item read answers rules 1 and 2 after the 405 verb check and before the mask posture, and rule 3 after it, as RestServer does. The one caller who can reach a decision unauthenticated is the public-audience exemption on a book or doc item read, on both transports alike; the refusal body is static (the unknown name and the fixed set), so it is no existence or capability oracle, and nothing a prior gate withholds is reachable through it. What changes between transports is only the 400-versus-501 order for a caller who already passed the capability gate on PUT, or sends a repeated package to a host with no layered read on /layers. Judged in ③.
  8. The published surface — right as graded; one consistency note. packages/rest/package.json publishes the single . entry (dist/index.*, no private), and src/index.ts gains exactly two value names, metaItemQueryRefusal and metaMemberSelector, in the gate module's existing value block. Nothing left the entry (the index diff is +9 / -0). None of the five META_ITEM_* constants joined it (zero hits in index.ts); they are module exports re-exported from rest-server.ts so slice 1's two test files import them unchanged. unknownQueryParamRefusalMessage is a module export of query-allowlist.ts that the entry does not re-export (the entry takes nothing from that module). The two types MetaItemQueryDoor and MetaItemQueryRefusal are module exports left out of the entry's export type block; under the shared tsup dts bundling they are emitted as local declarations beside the two functions, so a consumer gets the full structural type by inference and may name it only by deriving it, which is what domains/meta.ts does (NonNullable of the function's ReturnType). Usable, and within the ruling's one-to-two exports. The note: the sibling decisions' types (MetaTypeReadRefusal, MetaTypeWriteRefusal, MetaItemReadRefusal) ARE on that export type block, so this pair is the odd one out; adding the two names later is a type-surface-only widening. The seat's call, not a finding.
  9. query-allowlist.ts — right. refuseUnknownQueryParams now calls the extracted pure function and writes the same 400, the same nested envelope and the same message; the extraction moved the old body verbatim, and unknownQueryParamMessage, which every route's sentence comes from, is not touched. Every existing route keeps its exact answer.
  10. The objectui force-save path at the console pin — a changed failure, and the wider class is declared. The dispatcher never read force (item 3), so the post-409 retry Studio sends on a dispatcher-only host already failed with the protocol's 409; it now fails with the located 400 naming force before any save. No flow that worked goes red. The class the changeset declares beyond Studio: a caller who passes force: true pre-emptively on a non-destructive save through a dispatcher-only host went 200 on main (the name dropped, the save fine) and answers 400 now. That is exactly the narrowing the card exists to make loud, it is BREAKING and the changeset's PUT line carries its remedy (drop ?force=). The census (the SDK sends force only on force: true; no in-repo production caller does; record 6138381862 item 8 measured Studio at the same pin) is consistent with the diff.
  11. The caller census — holds on what this tree can show. @objectstack/client's metaSaveQuery emits force, package, mode; getItem sends package; getPublished nothing; no /layers caller; the conformance and hono suites that boot createHonoApp with the real dispatcher are in the head's Test Core shards, which are green.
  12. The parity pins — right. meta-list-projection-parity's ITEM_PROBES gains ?member=zh-CN and ?layers=true&member=zh-CN, its protocol double refuses a member on every non-bundle type as the protocol's first step does, and the reference-moves pin shows ?member= reaching the protocol on RestServer. The new runtime file compares each dispatcher refusal's code and message with RestServer's for the same request on the item read, /layers and /published, and pins PUT's own two-name sentence.

② Semver level

Right. Judged against AGENTS.md Post-Task Checklist step 3, scripts/pm/clause2-line.mjs, the two changeset gates' sources, and PR #23133's records.

  • '@objectstack/rest': minor — two new value exports widen the published entry; Clause-②: yes takes at least minor. Right.
  • '@objectstack/runtime': minor — four dispatcher accept sets narrow, which is BREAKING, shipped as minor under the launch-window convention the no-major gate's header states; the same grade slice 1 landed with and the records 6138381862 and 6139157658 ruled. Both packages are in the one fixed group. Right.
  • fix(runtime)!: — the summary line is the changeset body's first line, read by the ADR-0087 gate as signal (3). Right.
  • Clause-②: yes (narrowing) on PR line 2 and in the changeset, once — clause2-line.mjs names this spelling as "a diff that widens one surface and narrows another", the gate reads the arm as signal (4), and the precedent is on origin/main already (06cc45d in packages/rest/CHANGELOG.md under 18.0.0-next.0 carries yes (narrowing)). The only malformed arm is no (widening), which this head does not use. Right.
  • One ADR-0087 marker, not-required (no-migration-prescription), in the comment form the gate reads (the opening and closing comment brackets are spelled out here for the sanitizer), stating its facts: no spec key, authorable spelling or stored shape moves; nothing exported is removed or narrowed; both packages publish; no ADR-0087 id is named. Each checked against the diff: packages/spec is untouched, no *.object.ts moves, the index diff removes nothing, the only adr-0087 string in the diff is the marker. The body's per-door remedies are imperative request sentences with no FROM-to-TO rewrite pair, the same shape 6139157658 judged honest for the same category on slice 1. The launch-window **BREAKING** line is present once. Check Changeset and Lint & Repo Gates are success on this head. Right.
  • Pre mode changes nothing about the grade. origin/main's version commit d889448b93 (2026-10-13T07:37:56Z, chore: version packages (next)) bumped @objectstack/rest and @objectstack/runtime from 17.7.0 to 18.0.0-next.0 and moved the consumed changesets, slice 1's included, into .changeset/pre/; .changeset/pre.json reads mode: pre, tag next, on both origin/main and this head. The PR's merge base d4aaeaf0e2 predates that commit, but its base sha 150da61aa5 is the current origin/main, so CI's merge ref already carried the pre-mode tree when every gate above ran. With a pending major already consumed, minor and patch both yield 18.0.0-next.1, so the version string no longer carries the level; what the grade still decides is the CHANGELOG section (Minor Changes) and the ADR-0087 gate's breaking classification, and both are what consumers read, so the grade must stay truthful and does. The pre-mode exemption in the no-major gate admits only a major, which this head does not declare; the protocol-lockstep evidence the dev's gate line names is the tree's state, independent of this PR. The new changeset sits unconsumed at .changeset/, as it must.

③ Boundary flags

The report 6142050405 has no open_questions, which is right: the one question slice 1 raised (6137337775, A / B / C) is answered by ruling B 6138588561, and this head is that answer. Every dev flag and every finding, answered or escalated:

  • /layers answers arity before its 501 on the dispatcher, after it on RestServer — declared, and judged: only a request that is both malformed (a repeated package) and aimed at a host with no layered read answers 400 here and 501 there; both answers sit after the anonymous deny and the type-level refusals; through createHonoApp no repeated name reaches the dispatcher at all (next item), and RestServer's placement is slice 1's, not touched here. The same holds for PUT's decision sitting before the protocol resolve: the capability gate still answers first (pinned), so the 501-versus-200 probe the gate's placement protects against is untouched. A changed answer for a malformed request only. No action.
  • The merge of main — verified: the merge 689e8f66ca's second parent is d4aaeaf0e2, the merge base itself; it brought main's commits into three other files under packages/runtime/src/domains and packages/rest/src (packages.ts and two test files), none of the PR's nine paths; the post-merge delta to the final head is the one-line typing fix in the new runtime test (saveMetaItem's double takes a typed _request). The three-dot diff is the five-commit branch and nothing else. mergeable_state is blocked only on the owed review; the queue re-merges. No action.
  • Two NOT MEASURED re-runs (one runtime run cut by the lock wrapper's budget, one lock acquisition timed out and resumed) — declared, with the completed re-runs recorded, and answered by the head's check-runs: Test Core 1/6 to 6/6 and the aggregate are success (the last shard completed 2026-10-13T09:16:17Z, the aggregate 2026-10-13T09:16:42Z), beside Build Core, Dogfood Regression Gate, every Lint and Type Check run, Check Changeset, Governed Surface Queue Guard, the single-writer and card-claim guards: 32 success, 8 skipped (Console Pin Gate, the live-database and opt-in gates, as this diff's paths warrant), none failed, latest run per name. No action.
  • The trailer form — right: AGENTS.md (:459-:463) requires the model-free pair on every agent commit and the pre-push hook refuses a model identifier in it; all five commits carry Claude-Session and the model-free Co-authored-by, and the harness reminder's model-named form is the one the repo's rule outranks. The PR body's session-URL footer is the form AGENTS.md's comment section describes. No action.
  • Zero label writes, no skip-changeset — right: the changeset publishes. needs:contract-review was the seat's write in the ACCEPT.
  • out_of_scope_findings[0], class b: createHonoApp's catch-all flattens a repeated query name to its last value — verified at packages/adapters/hono/src/index.ts:789 (the catch-all builds queryParams with url.searchParams.forEach, assigning queryParams[key] = val on every pair, so the last value of a repeated key wins). Judged: it does NOT make this PR's arity rule wrong. The decision is correct, reachable through any host that hands the dispatcher arrays (the dev names plugin-hono-server's readQuery), and pinned by driving the dispatcher directly. What it makes PARTIAL is the parity claim on the one published composition the card is about: on a createHonoApp host, GET ...?member=zh-CN&member=ja-JP is answered with ja-JP where RestServer answers 400 "supplied 2 times", and the first value is dropped silently, which is route rule 5's "fails undetectably" one layer up. The family is query multiplicity at the adapter (packages/rest 的其它 req.query.* 读取点同样把 string | string[] 当字符串用(#6307 的未扩大部分) #6877 / 两个 IHttpServer 适配器对「重复的查询参数」给出不同形状:Hono 折叠成第一个值,node:http 给数组 #6878), it touches every dispatcher domain that declares an arity rule, and its fix is the adapter's, so a separate card is right. Prime Directive chore: version packages #10 then requires the card to exist: the ACCEPT says the seat files it after dedupe, and this record holds the seat to that filing before the card closes on this landing, with the dedupe words the report gives.
  • Residuals this record adds for the seat, neither a finding against this head: ① item 1 (RestServer's PUT and /layers arity lists remain hand-spelled beside the decision's once table) and ① item 8 (the decision's two types are not on the entry's export type block, where its siblings' are).
  • Scope and shape — 9 files, all inside the claim's file surface, query-allowlist.ts under its conditional (the recognition sentence is now the shared pure function); 1183 changed lines, under the 3,000 threshold; no governed path in the file list (check-governed-merges is not engaged, and the Governed Surface Queue Guard is success); Fixes #23072 on line 1 is right because this slice closes the card, and The card this PR closes must claim this branch is success; the PR is a draft, and landing is the seat's act after this record.

Everything in ① and ③ holds, and ② holds. Nothing in this record is a finding the seat must act on before landing; the two residuals and the class-b filing are follow-ups.

Implemented-by: claude/issue-23072-s2-dispatcher-read-decision
Reviewed-by: session_01VoSxBQujKLZKPwK2u5ehQ6

VERDICT: PASS


Generated by Claude Code

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