Repository navigation
fix(runtime)!: the dispatcher's read points refuse a repeated single-valued query name, as their RestServer twins do - #23263
Conversation
…23214 slice 1) Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…alued name as their RestServer twins do (#23214 slice 1) Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…d the shared /meta arity lists (#23214 slice 1) Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
slice 1) Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…23214 slice 1) Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…-dispatcher-arity-sweep
📓 Docs Drift CheckThis PR changes 2 package(s): 35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: Rendered 2026-10-13T13:31Z by the adopting seat's session, read-only, adversarially: card #23214's body and every comment (triage ① Derived judgmentsEvery accept-set and public-surface change the diff implies, named right or wrong against the handlers' ACTUAL read points on both transports.
② Semver levelRight. Judged against AGENTS.md Post-Task Checklist step 3,
③ Boundary flagsThe report
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: VERDICT: PASS Generated by Claude Code |
Part of #23214
Clause-②: yes (narrowing)
Slice 1 of #23214, under the seat ruling
6144365165(option A: guard first, then flip) and claim6144390225. #23214 remains open: slice 2, thecreateHonoAppadapter 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/honocreateHonoAppcatch-all, has always flattened a repeat to its last value. Slice 2 will make that adapter keep repeats as arrays, asplugin-hono-server'sreadQuerydoes. Round 1 measured what these points answer to the array shape (os-dev-report6144314970): wrong200s, arrays forwarded into protocol calls, and500s. Each point now refuses a repeated single-valued name with itsRestServertwin'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" is400in the dispatcher's envelope,{ success: false, error: { code, message } }. The message isrepeatedQueryParamMessage(name, n)(the multiplicity sentence), except on the/datafilter slot.GET /meta/object/:name/state/:fieldfromRestServerrefuses['from']200,from: 'done,todo',next: [](a dead end for a state that does not exist)400 VALIDATION_ERRORGET /meta/_draftspackageId,typeRestServerrefuses bothlistDrafts400 VALIDATION_ERRORGET /meta/:typepackage,preview,object,include,idRestServerrefuses all fiveobject:200 [];package: an unscoped list of every package;preview,id,include: read as absent400 VALIDATION_ERRORGET /meta/book/:name/treepackageRestServerrefuses it400 VALIDATION_ERRORGET /ui/view/:object(no type segment)type?type(REST mounts/ui/view/:object/:type)getUiView400 VALIDATION_ERRORGET /i18n/translations,GET /i18n/labels/:object(no locale segment)localeTypeErrorinresolveLocale, so a500400 VALIDATION_ERRORGET /security/suggested-bindingsstatus,packageIdRestServerrefuses both, in that orderpackageId: joined to'a,b',200 [];status:400with the unknown-status sentence400 VALIDATION_ERROR, multiplicity sentenceGET /packagesstatus,typeenabledrefusal===against an array:200with no packages400 VALIDATION_ERRORGET /data/:objectfilter,whereand their aliases,filters,$filter)RestServer's list asksassertFilterParamSuppliedOncefindData, where a filter AST is an array too400 INVALID_FILTER,RestServer's repeated-filter sentenceNot touched: genuinely multi-valued names (
selectandexpandstay arrays, pinned on the/datalist),?locale=on the/metadoors (its one reader falls back to the default locale on both transports by design), and the four by-name/metadoors, which already askmetaItemQueryRefusal.One source for the
/metaarity listsRestServerspelled four/metaarity lists inline: the list door, the book tree,_draftsand the state machine read. They move topackages/rest/src/meta-item-read-gate.tsbesidemetaItemQueryRefusal, asMETA_QUERY_ARITY_DOORS, with the decisionmetaQueryArityRefusal(door, query).RestServerhands each list torefuseRepeatedQueryParams, 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
objectandpackageon the list door. It ran 50 cases throughRestServer'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 from06dbc4e59dinto the tree only. The HEAD leg ran at45db8870ca, after a restore proven by an emptygit diff HEADand matching blob hashes. The two dumps are byte-identical (cmpsilent; sha256 prefixc63cb9403588e830on both; 18 refusals among the 50 cases). The scratch harness was never committed.meta-query-arity-doors.test.tsalso pins thatRestServerrefuses 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/packagesas points with no shared list._drafts(:5861) and the state read (:8225) are in neither group, but they are/metadoors whose twins spell an arity list. So they moved too, under the ruling's general rule ("whereRestServerspells an arity list, that list moves"), which the claim'srest-server.tssurface ("the arity lists' spelling") allows./security/suggested-bindings' twin also spells a list (['status', 'packageId']), butmeta-item-read-gate.tsis 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
/datafilter slotassertFilterParamSuppliedOncejoins@objectstack/rest's entry (it was already exported fromquery-multiplicity.ts; that file is untouched). The dispatcher's/datalist asks it on the copyfindDatareceives, and answers its throw in the dispatcher's envelope with the code (INVALID_FILTER) and sentence it carries.FILTER_SLOT_QUERY_PARAMSis 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 withURLSearchParams.setor a single template (?from=,?package=), andui.getViewand the i18n reads put the type and locale in the path. The onlyappendcalls aredata.find's plain filter map, which writes each key of one object once. No repeat..objectui-sha1fb1ced362(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-exampleURLSearchParams.appendsites build the console's own browser URL state (filter[…],uf_*), not API requests.packages/andexamples/(8,595 files scanned): the only literal repeats target REST routes this PR does not change, in REST's own multiplicity test. There are noappendcalls inpackages/qaand no array-valued queries handed todispatch()on these routes.So, as mechanism assumption 1 expected, the narrowing is visible on a shipped host only at
GET /packagesthroughplugin-hono-server, whose adapter already keeps repeats as arrays. Everywhere else it is visible only to a directHttpDispatcher.dispatch()caller that passes an array, until slice 2. The changeset says so.Read only: the dispatcher's
DELETE /data/:object/:idMeasurable at a public door: yes. Driven through the real
createHonoAppover the real dispatcher, with a recordingdeleteData:DELETE /api/v1/data/task/r1?expectedVersion=7and the same request withIf-Match: "7"both answer200, anddeleteDatareceives{ object: 'task', id: 'r1' }with no version. That is identical to a request that sends none.RestServer's twin forwardsexpectedVersion(query orIf-Match) todeleteData. So on acreateHonoApphost a caller's optimistic-concurrency guard is silently dropped. Not changed here, per the dispatch.Responsibility
git blameat06dbc4e59dfor 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 (8891f93948for/metaand/data,21ca1d530cfor/ui,8f124a7b77for/i18nand/security,d729a31160for/packages), which moved olderhttp-dispatcher.tscode. The/datalist line isfd8521f923(2026-08-04); the/metalist'spreviewis5c7aa46748and the view switcher'sobject95f729a1ad(both 2026-09-28);?package=on the list and book tree isfbcbcf124f(2026-10-08); and/securitystatusisbca935b9ac(2026-07-30). #6877's sweep commitf3f855ac15(2026-08-10) touched no runtime domain or dispatcher file. The two later commits citing #6877 that editeddomains/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 coveredpackages/restonly.Tests and gates (HEAD
9107ffa4c9, after mergingorigin/main)@objectstack/runtime:vitest run --project local: 368 files passed, 5254 tests passed, 19 skipped,VERDICT command-exit 0. Before the merge, at11725bfec8: 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(runtimerepoproject): 739 passed.tsc --noEmitpluscheck:test-typecheck): exit 0. Runtime test-layer debt is unchanged (27 files, 190 errors, 68 signatures); rest's is 0.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-loadsexits 3,PREREQUISITE NOT MET(it reads every package'sdist/; 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/restand@objectstack/runtimeload underrequire, and rest's carries the three new exports.--ranreconciliation: "65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN".pnpm check:adr-0087-registrationexit 0 ([BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription));node scripts/check-changeset-no-major.mjs --base origin/mainexit 0, plus its--eventform on this PR's title and body;pnpm check:route-envelopeexit 0;pnpm check:nul-bytesexit 0..tsfiles. Population: the config's ownfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']block, with none of the 11 ignored. Count:--format jsonreports 11 files, 0 errors, 0 warnings, 0 fatal. Invariance:eslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict.Ablations (restore traps; restores proven by
git diff HEADand 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 ordistleg applies: the pins reach the code under test through relative imports, and runtime's vitest aliases@objectstack/restto its source (packages/runtime/vitest.config.ts:149)./metastatedoor's refusalfromrefusal)frompreservation/uityperefusal)/i18n/securitystatus,packageId)/packagesstatus,type)/dataassertFilterParamSuppliedOnce(listQuery)callNo preservation pin went red in any leg.
Acceptance notes
/security/suggested-bindingscarries a second spelling of['status', 'packageId'], besideRestServer's twin, as the dispatch directed. A shared home for non-/metaarity lists would end it; it was not created here._draftsrefuse at the pointRestServerrefuses: after the protocol is looked up, and for_draftsafter the capability gate and thelistDraftsprobe. The/datafilter refusal answers beforecallData's exposure gate, whereRestServeranswers after its own. For a repeated filter on an object whose API is not exposed, this transport therefore answers400before403.@objectstack/restexports:META_QUERY_ARITY_DOORS,metaQueryArityRefusalandassertFilterParamSuppliedOnce. The door type is not exported, matchingmetaItemQueryRefusal's.Generated by Claude Code