Skip to content

fix(runtime)!: the dispatcher's read points refuse a repeated single-valued query name, as their RestServer twins do - #23263

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23214-s1-dispatcher-arity-sweep
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23214-s1-dispatcher-arity-sweep

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #23214
Clause-②: yes (narrowing)

Slice 1 of #23214, under the seat ruling 6144365165 (option A: guard first, then flip) and claim 6144390225. #23214 remains open: slice 2, the createHonoApp adapter flip, is the change that ends it, and it is dispatched only after this one lands.

What this does

The runtime dispatcher's read points each read a query name as ONE value. None of them guarded against the name arriving twice, because the only door in front of most of them, the @objectstack/hono createHonoApp catch-all, has always flattened a repeat to its last value. Slice 2 will make that adapter keep repeats as arrays, as plugin-hono-server's readQuery does. Round 1 measured what these points answer to the array shape (os-dev-report 6144314970): wrong 200s, arrays forwarded into protocol calls, and 500s. Each point now refuses a repeated single-valued name with its RestServer twin's code and sentence, before any protocol or service call it feeds. A name sent once is answered as before, and one sent once as a one-element array now reads as that value.

The points

Before = the answer to a repeat handed to dispatch() as an array (measured in round 1). After = this PR. Every "after" is 400 in the dispatcher's envelope, { success: false, error: { code, message } }. The message is repeatedQueryParamMessage(name, n) (the multiplicity sentence), except on the /data filter slot.

route name(s) twin's rule before after
GET /meta/object/:name/state/:field from RestServer refuses ['from'] 200, from: 'done,todo', next: [] (a dead end for a state that does not exist) 400 VALIDATION_ERROR
GET /meta/_drafts packageId, type RestServer refuses both the array reached listDrafts 400 VALIDATION_ERROR
GET /meta/:type package, preview, object, include, id RestServer refuses all five object: 200 []; package: an unscoped list of every package; preview, id, include: read as absent 400 VALIDATION_ERROR
GET /meta/book/:name/tree package RestServer refuses it read as absent: the tree spans every package 400 VALIDATION_ERROR
GET /ui/view/:object (no type segment) type no REST twin reads ?type (REST mounts /ui/view/:object/:type) the array reached getUiView 400 VALIDATION_ERROR
GET /i18n/translations, GET /i18n/labels/:object (no locale segment) locale no REST twin TypeError in resolveLocale, so a 500 400 VALIDATION_ERROR
GET /security/suggested-bindings status, packageId RestServer refuses both, in that order packageId: joined to 'a,b', 200 []; status: 400 with the unknown-status sentence 400 VALIDATION_ERROR, multiplicity sentence
GET /packages status, type this domain's own enabled refusal === against an array: 200 with no packages 400 VALIDATION_ERROR
GET /data/:object the filter slot (filter, where and their aliases, filters, $filter) RestServer's list asks assertFilterParamSuppliedOnce the array reached findData, where a filter AST is an array too 400 INVALID_FILTER, RestServer's repeated-filter sentence

Not touched: genuinely multi-valued names (select and expand stay arrays, pinned on the /data list), ?locale= on the /meta doors (its one reader falls back to the default locale on both transports by design), and the four by-name /meta doors, which already ask metaItemQueryRefusal.

One source for the /meta arity lists

RestServer spelled four /meta arity lists inline: the list door, the book tree, _drafts and the state machine read. They move to packages/rest/src/meta-item-read-gate.ts beside metaItemQueryRefusal, as META_QUERY_ARITY_DOORS, with the decision metaQueryArityRefusal(door, query). RestServer hands each list to refuseRepeatedQueryParams, re-pointed in spelling only, and the dispatcher asks the decision over the same list. metaItemQueryRefusal's rule 2 and the new decision share one private implementation of the rule.

Byte-identity proof: a BASE/HEAD differential, because the unchanged REST pins cover only object and package on the list door. It ran 50 cases through RestServer's four route handlers: every declared name twice and three times, as a one-element array, as an empty array and as one value; two repeated names; an undeclared repeat; and no query. It recorded each status, body, headers and protocol call. The BASE leg ran with the three rest files restored from 06dbc4e59d into the tree only. The HEAD leg ran at 45db8870ca, after a restore proven by an empty git diff HEAD and matching blob hashes. The two dumps are byte-identical (cmp silent; sha256 prefix c63cb9403588e830 on both; 18 refusals among the 50 cases). The scratch harness was never committed. meta-query-arity-doors.test.ts also pins that RestServer refuses exactly the names the shared list declares, door by door, so the list is the one both read.

Scope note, stated rather than chosen silently. The dispatch names the list door (:6013) and the book tree (:6288) as the lists that move, and groups I1, I2, S1, S2, U1 and /packages as points with no shared list. _drafts (:5861) and the state read (:8225) are in neither group, but they are /meta doors whose twins spell an arity list. So they moved too, under the ruling's general rule ("where RestServer spells an arity list, that list moves"), which the claim's rest-server.ts surface ("the arity lists' spelling") allows. /security/suggested-bindings' twin also spells a list (['status', 'packageId']), but meta-item-read-gate.ts is no home for a security route, and the dispatch places it in the no-shared-list group. So the dispatcher spells those two names at its own read point beside the shared sentence. That is a second spelling of a two-name list, recorded below.

The /data filter slot

assertFilterParamSuppliedOnce joins @objectstack/rest's entry (it was already exported from query-multiplicity.ts; that file is untouched). The dispatcher's /data list asks it on the copy findData receives, and answers its throw in the dispatcher's envelope with the code (INVALID_FILTER) and sentence it carries. FILTER_SLOT_QUERY_PARAMS is not exported: the dispatcher does not need the list itself. The pin computes REST's expected answer by calling the same imported rule, never by re-spelling it.

Census: who sends a repeat to these routes today

  • @objectstack/client (SDK): every swept name is written with URLSearchParams.set or a single template (?from=, ?package=), and ui.getView and the i18n reads put the type and locale in the path. The only append calls are data.find's plain filter map, which writes each key of one object once. No repeat.
  • objectui at the console pin .objectui-sha 1fb1ced362 (a read-only depth-1 fetch; nothing executed): direct requests to these paths are single-occurrence templates (/api/v1/meta/_drafts?packageId=…, GET /meta/object?package=…&preview=draft). Its three non-example URLSearchParams.append sites build the console's own browser URL state (filter[…], uf_*), not API requests.
  • dogfood and http-conformance suites, and every string literal under packages/ and examples/ (8,595 files scanned): the only literal repeats target REST routes this PR does not change, in REST's own multiplicity test. There are no append calls in packages/qa and no array-valued queries handed to dispatch() on these routes.

So, as mechanism assumption 1 expected, the narrowing is visible on a shipped host only at GET /packages through plugin-hono-server, whose adapter already keeps repeats as arrays. Everywhere else it is visible only to a direct HttpDispatcher.dispatch() caller that passes an array, until slice 2. The changeset says so.

Read only: the dispatcher's DELETE /data/:object/:id

Measurable at a public door: yes. Driven through the real createHonoApp over the real dispatcher, with a recording deleteData: DELETE /api/v1/data/task/r1?expectedVersion=7 and the same request with If-Match: "7" both answer 200, and deleteData receives { object: 'task', id: 'r1' } with no version. That is identical to a request that sends none. RestServer's twin forwards expectedVersion (query or If-Match) to deleteData. So on a createHonoApp host a caller's optimistic-concurrency guard is silently dropped. Not changed here, per the dispatch.

Responsibility

git blame at 06dbc4e59d for each swept read line, not shallow. All of them are later than the flattening's origin, 6ac9f8c96f (2026-02-13). Most come from the ADR-0076 D11 domain extractions of 2026-07-27 (8891f93948 for /meta and /data, 21ca1d530c for /ui, 8f124a7b77 for /i18n and /security, d729a31160 for /packages), which moved older http-dispatcher.ts code. The /data list line is fd8521f923 (2026-08-04); the /meta list's preview is 5c7aa46748 and the view switcher's object 95f729a1ad (both 2026-09-28); ?package= on the list and book tree is fbcbcf124f (2026-10-08); and /security status is bca935b9ac (2026-07-30). #6877's sweep commit f3f855ac15 (2026-08-10) touched no runtime domain or dispatcher file. The two later commits citing #6877 that edited domains/meta.ts (7986d973fa, d2b2381a36) added no arity rule there. So the claim's reading holds: these points were written or moved behind an adapter that flattened repeats, and #6877 covered packages/rest only.

Tests and gates (HEAD 9107ffa4c9, after merging origin/main)

  • @objectstack/runtime: vitest run --project local: 368 files passed, 5254 tests passed, 19 skipped, VERDICT command-exit 0. Before the merge, at 11725bfec8: 367 files, 5238 passed.
  • @objectstack/rest: vitest run --project local: 279 files passed, 5831 tests passed, 326 skipped, VERDICT command-exit 0. Before the merge: 278 files, 5825 passed.
  • meta-list-projection-parity (runtime repo project): 739 passed.
  • Typecheck, both packages (tsc --noEmit plus check:test-typecheck): exit 0. Runtime test-layer debt is unchanged (27 files, 190 errors, 68 signatures); rest's is 0.
  • The derived gate list (dispatch-gates --commands, no paths, 65 commands) ran each with its exit captured before any pipe: 64 exit 0. One is NOT MEASURED: pnpm check:dual-build-cjs-loads exits 3, PREREQUISITE NOT MET (it reads every package's dist/; a full build here is 31 turbo cache misses including @objectstack/console, so CI runs it). As a scoped supplement, the CJS builds of @objectstack/rest and @objectstack/runtime load under require, and rest's carries the three new exports. --ran reconciliation: "65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN".
  • Named by the dispatch: pnpm check:adr-0087-registration exit 0 ([BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)); node scripts/check-changeset-no-major.mjs --base origin/main exit 0, plus its --event form on this PR's title and body; pnpm check:route-envelope exit 0; pnpm check:nul-bytes exit 0.
  • Lint, as a proven narrowing over the 11 touched .ts files. Population: the config's own files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] block, with none of the 11 ignored. Count: --format json reports 11 files, 0 errors, 0 warnings, 0 fatal. Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict.

Ablations (restore traps; restores proven by git diff HEAD and blob hash)

Each leg ran through scripts/ablation-replace.mjs (WRAP; the anchor hit once, its count went from 1 to 0, and the blob moved). No build or dist leg applies: the pins reach the code under test through relative imports, and runtime's vitest aliases @objectstack/rest to its source (packages/runtime/vitest.config.ts:149).

leg mutation red green
/meta remove the state door's refusal 1 (from refusal) 32, including the from preservation
/ui short-circuit the array branch 1 (type refusal) 32
/i18n raise the repeat threshold to 99 2 (both arms' refusals) 31, including the preservation
/security raise the repeat threshold to 99 2 (status, packageId) 31
/packages raise the repeat threshold to 99 2 (status, type) 31
/data delete the assertFilterParamSuppliedOnce(listQuery) call 5 (four filter spellings, plus the one-element unwrap, which is the rule's own) 28, including the one-filter preservation

No preservation pin went red in any leg.

Acceptance notes

  • /security/suggested-bindings carries a second spelling of ['status', 'packageId'], beside RestServer's twin, as the dispatch directed. A shared home for non-/meta arity lists would end it; it was not created here.
  • The book tree and _drafts refuse at the point RestServer refuses: after the protocol is looked up, and for _drafts after the capability gate and the listDrafts probe. The /data filter refusal answers before callData's exposure gate, where RestServer answers after its own. For a repeated filter on an object whose API is not exposed, this transport therefore answers 400 before 403.
  • New @objectstack/rest exports: META_QUERY_ARITY_DOORS, metaQueryArityRefusal and assertFilterParamSuppliedOnce. The door type is not exported, matching metaItemQueryRefusal's.

Generated by Claude Code

@github-actions github-actions Bot added size/l 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 24 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.

35 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 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d.

⛔ 10 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 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 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d

⚠️ 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 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d → 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: 9107ffa4c9afb74bd62fb8183df28110ab4cf74b
Local-runs: none

Rendered 2026-10-13T13:31Z by the adopting seat's session, read-only, adversarially: card #23214's body and every comment (triage 6143014548, claim 6143999704, the round-1 stop 6144314970, ruling 6144365165, the slice-1 claim 6144390225, the dev's report 6146257839, and the ACCEPT 6146305565 read as a claim to test); PR #23211's record 6142424713 as the pattern and precedent; PR #23263's body, file list and the net diff of refs/pull/23263/head against its merge base afbf960c26 (12 files, +725 / -25, read whole); the head's files where the diff lacked context (domains/meta.ts, data.ts, ui.ts, i18n.ts, security.ts, packages.ts, http-dispatcher.ts, error-envelope.ts, action-execution.ts, rest-server.ts at every refuseRepeatedQueryParams site, meta-item-read-gate.ts, query-multiplicity.ts, index.ts, package.json, package-routes.ts, the Hono adapter's catch-all, the three changeset gates' sources, spec's status-to-code map); card #23265; and the head's check-runs, polled four times over twenty-five minutes until every run had concluded. Nothing was built, run, re-run or ablated. The dev's BASE/HEAD differential and six ablations were not re-run and are not needed: each claim they support is re-derived from the diff below.

① 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. Per route, the multiplicity set equals the twin's declared set — right, on every route with a twin. Each dispatcher set, read off the head's code, beside its RestServer twin's list at the head:
    • GET /meta/object/:name/state/:field: ['from'] on both (the dispatcher reads only query.from, meta.ts:1026; REST :8232).
    • GET /meta/_drafts: ['packageId', 'type'] on both (the dispatcher reads only those two, :1920-:1921; REST :5863).
    • GET /meta/:type: ['package', 'preview', 'object', 'include', 'id'] on both (REST :6017). The dispatcher's reads after the decision: package through metaItemPackageBinding (:2005), preview as a string (:2051), and id, object, include inside the shared createMetaListAnswer (meta-item-read-gate.ts:3321, :3333, :3356), which receives the same query object the decision unwrapped in place, so a one-element array reaches the chain as the string. locale is read by metaRequestLocale and is deliberately outside the once list on both transports (typeof-guarded to the default; pinned). No name read is outside the set; no name in the set goes unread.
    • GET /meta/book/:name/tree: ['package'] on both (:1061; REST :6293).
    • GET /security/suggested-bindings: ['status', 'packageId'] on both, in that order (security.ts:163; REST :11612).
    • GET /packages: ['status', 'type'] beside the existing enabled refusal. REST has no second reading of this door: package-routes.ts records that the dispatcher domain is the single implementation its registrar shadows, so the twin is the domain's own enabled refusal, mirrored line for line, including the bare deps.error(message, 400) whose code derives as VALIDATION_ERROR from spec's HttpStatusErrorCodeMap[400] (pinned).
    • GET /data/:object, the filter slot: FILTER_SLOT_QUERY_PARAMS is ['where', 'filter', 'filters', '$filter'] (the where slot's one alias is filter, data-engine.zod.ts:603); the pins cover all four. select and expand are not in that list, are never touched, and are pinned as arrays. The 两个 IHttpServer 适配器对「重复的查询参数」给出不同形状:Hono 折叠成第一个值,node:http 给数组 #6878 ruling's multi-valued names are untouched; /data/:object/:id's select and expand pass-through is not in the diff.
    • The four by-name /meta doors are not in the diff and keep metaItemQueryRefusal.
      No name is refused that a twin accepts repeated, and no legitimately multi-valued name is touched.
  2. The three dispatcher-only reads — right under route rule 5 and the 两个 IHttpServer 适配器对「重复的查询参数」给出不同形状:Hono 折叠成第一个值,node:http 给数组 #6878 ruling. /ui/view/:object ?type (REST mounts /ui/view/:object/:type only, rest-server.ts:8648; no REST route reads ?type) and the two /i18n query-locale arms (REST mounts no /i18n route) have no twin. Each handler reads the name as ONE value, and after slice 2 it will SEE the repeat as an array: the 两个 IHttpServer 适配器对「重复的查询参数」给出不同形状:Hono 折叠成第一个值,node:http 给数组 #6878 ruling's condition for refusing is met at the read point itself, and the alternative (last-wins or first-wins) is the silent choice between two expressed intents the finding: a repeated ?filter= on GET /data/:object cannot be told from a filter AST, so it is diagnosed as a malformed filter (and, rarely, succeeds) #7390 ruling refused. ?type is read only when the path names no type (ui.ts:106): a repeated ?type beside a path type is never read, and is pinned as ignored. ?locale is read only when the path names no locale (i18n.ts:140, :168); a repeat used to throw inside resolveLocale and surface as the catch-all's 500, which is a defect by any reading, so a located 400 is strictly better. An empty array reads as absent on all three, as readSingleQueryValue reads it. Right.
  3. Ordering against the calls each refusal feeds — right on all ten routes. Each refusal answers before the protocol or service read it protects, and each refusal pin asserts the service was never called: state before getObjectQL and legalNextStates; _drafts before listDrafts; the list before refuseUnknownMetaListType and getMetaItems; the book tree before getMetaItems; /ui before getUiView; /i18n before getTranslations and getFieldLabels; /security before the status vocabulary and listAudienceBindingSuggestions; /packages before getAllPackages; /data before callData.
  4. Ordering against the gates the twins answer after — right, with the /data case judged separately in item 5. On /meta all four decisions sit after the anonymous deny (meta.ts:939-:946) and the type-level read refusal (:960), which precede every branch; _drafts additionally after its capability 403, the protocol resolve and the listDrafts probe, exactly where REST asks it (:5844-:5863); the book tree after resolveProtocol, as REST (:6291-:6293); the list as the branch's first statement, which is REST's handler's first statement after the auth middleware (:5994), so on both transports arity precedes the unknown-type refusal. /security sits after the anonymous deny and the service probe, before the status vocabulary, the same order as REST (:11606-:11612). /packages sits after requireReadCapability, where the enabled refusal already sits, for the reason that site records. /i18n sits after the anonymous deny and the service availability gate. /ui has no gate of its own before the branch; the refusal precedes the 501 for a host without getUiView, the same class as PR 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's /layers placement: a malformed request to a host with no handler answers 400 where it answered 501. The one caller who reaches a /meta decision unauthenticated is the public-audience exemption on a book or doc list and on the book tree (isPublicAudienceRead), which REST's enforceAuth exempts by the same predicate, so the arity refusal precedes the audience gate on both transports alike. Every refusal body is the name and its count: it discloses nothing about existence, exposure or capability, so no caller learns from a 400 anything a prior gate withholds.
  5. The /data filter slot's placement — judged explicitly: a changed order for a malformed request only; not a defect. REST's list handler answers enforceAuth (401), then enforceApiAccess (the ADR-0049 exposure 403), then assertFilterParamSuppliedOnce (rest-server.ts:8726-:8761). The dispatcher's /data domain has no anonymous deny of its own (dispatch() defers to upstream auth, http-dispatcher.ts:1519; the exposure gate is inside callData, action-execution.ts:293-:304), so the new refusal precedes BOTH the exposure 403 and whatever an anonymous caller is answered inside callData. The dev declared the 403 half; this record names the 401 half too. What a caller can learn: nothing. The 400 is answered regardless of exposure, so it carries no exposure fact; the 403 a well-formed request still receives is the only carrier of that fact, and it is unchanged. REST itself is not uniform here: its own DELETE /data/:object/:id asks refuseRepeatedQueryParams(['expectedVersion']) BEFORE enforceApiAccess (:9061, :9067). Accepted as recorded in the acceptance notes.
  6. One source — right; the re-point is proven by the diff itself. META_QUERY_ARITY_DOORS and metaQueryArityRefusal live beside metaItemQueryRefusal, and rule 2 of that decision is now the same private repeatedOnceRefusal the new decision calls: the moved loop is the old loop verbatim (same Array.isArray check, same readSingleQueryValue, same repeatedQueryParamMessage(name, read.count), same in-place record[name] = read.value, same rule: 'repeated-parameter'). rest-server.ts changes four lines, each refuseRepeatedQueryParams(req, res, LITERAL) to refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.DOOR), and each constant's array is the literal it replaces, element for element and in the same order (drafts, list, book-tree, state). Since refuseRepeatedQueryParams iterates the names in order over an identical array, the answers are byte-identical by construction; the dev's 50-case differential is consistent and was not needed. No other spelling of any of the four lists exists at the head outside the gate module and its pins (repo grep). The ['preview'] list at :10100 is the analytics dataset route, not a /meta list. The one list now spelled on both transports is /security's ['status', 'packageId'] (security.ts:163, rest-server.ts:11612), judged in ③.
  7. The /data filter slot imports the rule — right, and the unwrap changes no other read. data.ts:21 imports assertFilterParamSuppliedOnce from @objectstack/rest (the runtime already depends on it, workspace:*); FILTER_SLOT_QUERY_PARAMS is not re-spelled anywhere in the runtime. The rule runs on listQuery = { ...query }, a shallow copy, and callData receives that copy: before this diff the handler spread { ...query } inline at the same call, so the copy semantics are unchanged and the dispatcher's own query object is never written. The list branch reads nothing else from query and returns. The only new behaviour beside the refusal is the one-element unwrap on the copy, which is REST's behaviour since finding: a repeated ?filter= on GET /data/:object cannot be told from a filter AST, so it is diagnosed as a malformed filter (and, rarely, succeeds) #7390 and is declared in the changeset and pinned. The throw is answered as deps.error(e.message, 400, { code: e.code }), so the code is INVALID_FILTER and the sentence is REST's repeatedFilterParamMessage; the pin computes the expected pair by calling the same imported rule, never by re-spelling it.
  8. The published surface — right as graded. packages/rest/package.json publishes the single . entry (dist/index.*, not private, unchanged by this diff). src/index.ts gains exactly three value names: assertFilterParamSuppliedOnce (a module export of query-multiplicity.ts since finding: a repeated ?filter= on GET /data/:object cannot be told from a filter AST, so it is diagnosed as a malformed filter (and, rarely, succeeds) #7390; that file is untouched), META_QUERY_ARITY_DOORS and metaQueryArityRefusal (new in the gate module). The index diff is +14 / -1, the one removed line being the re-spelled export that keeps refuseRepeatedQueryParams and repeatedQueryParamMessage; nothing leaves the entry, and no type joins it. The dev's CJS supplement (both dist/index.cjs load, rest's carrying the three names) is consistent with the head's Build Core and type-check runs. Two consistency notes, neither a finding: the type MetaQueryArityDoor is a module export left off the entry's export type block, exactly as MetaItemQueryDoor is (PR 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's record, item 8), so a consumer derives it from the function's parameter type; and the entry's new comment at index.ts:140-:141 says "the two /meta doors that are not by-name (the list and the book tree)" where the constant declares four doors. Comment only; the constant, its docblock and the pins agree on four.
  9. The pins — right, both halves per route. dispatcher-query-arity.test.ts boots the real HttpDispatcher as createHonoApp does, with recording doubles and an authoring caller, and pins per route the status, success: false, the nested error.code, the exact sentence and the service never called, beside a preservation pin on the arguments the service received for one value (and for a one-element array where that read changed). /packages runs over a real SchemaRegistry. The set itself is pinned once (META_QUERY_ARITY_DOORS equals the four lists) and read from the constant for the per-name cases, so a name added to the list is pinned without a second spelling. meta-query-arity-doors.test.ts pins the decision as data and drives RestServer's four handlers to show each refuses exactly the shared names with the nested 400 before any protocol read, plus one-value preservation. Consistent with route rule 5's two halves.
  10. The caller census — holds on what this tree can show. The names the SDK writes are single-set spellings; no in-repo literal repeats one of these names on these routes; and on a shipped host only GET /packages through plugin-hono-server's mounts (dispatcher-plugin.ts:1392-:1398) receives arrays today, where a repeated status or type answered an empty 200 and now answers the located 400. Every other point is behind createHonoApp's catch-all, which still flattens (packages/adapters/hono/src/index.ts:789), so the narrowing is visible there only after slice 2. The changeset says so.

② Semver level

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

③ Boundary flags

The report 6146257839 has no open_questions, which is right: ruling 6144365165 answered both questions round 1 raised, and this head is slice 1 of that answer. Every dev deviation and finding, answered or escalated:

  • The _drafts and state lists moved beyond the two the dispatch named — right. The ruling's general rule ("where RestServer spells an arity list in rest-server.ts, that list moves beside the shared decision") covers every /meta door whose twin spells one; the claim's rest-server.ts surface is "the arity lists' spelling"; and the move is spelling-only (item 6). Leaving two of four /meta lists inline would have been the half-measure the ruling's one-source rule exists to end. No action.
  • /security's two-name list kept at the dispatcher's read point — acceptable at this head, and the one residual this record carries. The ruling placed S1 and S2 in the no-shared-list group and named meta-item-read-gate.ts, a /meta module, as the home; a security route does not belong there, and inventing a new home was outside the claim. The result is one two-name list spelled on both transports, which is the shape the one-source rule is against, declared in the acceptance notes. Carried for the seat: a shared home for non-/meta arity lists, or a re-point of REST :11612 to a constant the dispatcher imports, in a later slice. Not a finding against this head.
  • Three exports, not one — right. The ruling itself prescribes that "both transports import" the moved lists, which needs the list and the decision on the entry; the claim's "one new export" counted the filter rule alone. Three is the honest count, the PR, the changeset and the marker all declare three, and the grade is unchanged. No action.
  • Two scratch files outside the surface, deleted — verified: the PR's file list is the twelve paths the report names, with no scratch file among them, and the three-dot diff adds no other path. No action.
  • The /data ordering — judged in ① item 5, including the 401 half the dev did not name. No action.
  • One merge of main — verified: 9107ffa4c9's second parent afbf960c26 is the merge base itself, so the three-dot diff is the five branch commits and nothing else; the merge commit carries no trailers, as a git-generated merge does, and the five authored commits carry Claude-Session and the model-free Co-authored-by. No action.
  • check:dual-build-cjs-loads NOT MEASURED — declared with its prerequisite reason and a scoped supplement (both CJS entries load; rest's carries the three names), and answered by the head's build and lint families: Build Core, Lint & Repo Gates, Lint · repo & tooling gates, every Type Check run and Check Changeset are success. This record did not map the gate to one named run. No action.
  • The class-a out-of-scope finding: the dispatcher's DELETE /data/:object/:id drops expectedVersion and If-Match — verified at the head: data.ts:130 calls callData('delete', { object, id }), action-execution.ts:399 forwards { object, id, environmentId, context } with no version, and REST's twin reads ?expectedVersion or If-Match behind its arity gate and forwards it (rest-server.ts:9061-:9072). The dev measured it at createHonoApp, a public door, so under Prime Directive chore: version packages #10 it is a card, not a note, and the ruling's "report it if measurable" is met. The card exists: runtime: the dispatcher's DELETE /data/:object/:id drops expectedVersion / If-Match, so a stale delete is applied where REST refuses it #23265 (finding, filed 2026-10-13T13:12Z by the seat), carrying the repro, the governing text, the acceptance and a dedupe line; triage grades it. Right, and rightly not fixed here: the dispatch made it read-only. No action on this head.
  • The docs-drift check — a flag, not a verdict (Flag docs affected by code changes is success); its 35 pages were not walked. Nothing in this diff changes a documented input: a repeated single-valued query name was never a documented accept, so no hand-written page can have stated the behaviour this PR removes. No action.
  • Check-runs on 9107ffa4c9 — latest run per name, polled last at 2026-10-13T13:31Z, every run concluded: 31 success (Build Core, Check Changeset, Governed Surface Queue Guard, every Lint and Type Check run, Dogfood Regression Gate, Test Core 1/6 through 6/6, the last shard completing at 2026-10-13T13:29:54Z, the single-writer, card-claim and part-of guards), 8 skipped (Console Pin Gate, the live-database and opt-in gates, as this diff's paths warrant), none failed, none in progress. Three earlier polls over twenty-five minutes saw Test Core (3/6) still running; the gate verdicts are the check-runs', and they are green. No action.
  • Scope and shape — 12 files, all inside the claim's surface; 750 changed lines, under the 3,000 threshold; no governed path (Governed Surface Queue Guard is success); Part of #23214 on line 1 is right because slice 2 ends the card, and Part-of PR must not also close its card is success; the PR is a draft with no review submitted; needs:contract-review was the seat's write in the ACCEPT.
  • Residuals this record adds for the seat, neither a finding against this head: the /security list's second spelling (above), the MetaQueryArityDoor type off the entry beside its MetaItemQueryDoor sibling, and the "two doors" comment at index.ts:140 that should say four.

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

Implemented-by: claude/issue-23214-s1-dispatcher-arity-sweep
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants