Uh oh!
There was an error while loading. Please reload this page.
fix(rest): refuse a repeated ?filter= as a repetition, not as a malformed filter (#7390) - #8004
Conversation
…rmed filter (#7390) Since #6878 route 2 (PR #7396) the Hono adapter surfaces repeated query parameters as arrays, so a repeated `?filter=` on `GET /data/:object` now reaches the shared list-query normalizer. That normalizer cannot judge the slot: a filter AST IS an array (`["status","=","open"]`), so #7386's arity gate had to leave it alone, and the two ingresses it serves — this querystring and `POST /data/:object/query`'s arbitrary-JSON body — are byte-identical there. Two shapes came out of that, both live: ?filter={"a":1}&filter={"b":2} -> 400, diagnosed as MALFORMED ?filter=status&filter=%3D&filter=open -> 200, applying a filter nobody wrote The first told a caller whose filters were both well-formed to check their AST syntax. The second spelled a valid AST by accident and succeeded. The arity judgement now happens at the REST querystring parse, the only layer that knows it is looking at a querystring — there an array on the filter slot is a repeated parameter and can be nothing else, so the normalizer stays free of the heuristic #4181 and #4121 removed. All four wire spellings of the one slot are covered (`filter`/`where` derived from the spec's own RPC_QUERY_ALIAS_SLOTS, `filters`/`$filter` wire-only). Refused, never resolved (maintainer ruling 2026-08-11): last-wins and AND-merge each silently serve one of two intents the caller expressed. The gate throws rather than responding, so the answer keeps the flat `mapDataError` envelope this route's other filter refusals already use — one slot, one wire code (`INVALID_FILTER`, already standard-catalog), one body shape, whether the filter was unreadable or sent twice. Unaffected: a single `?filter=` in both accepted forms, the POST body face, the genuinely multi-valued parameters, and a one-element array from a repeat-preserving adapter (one occurrence, unwrapped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — Every pin is here, including the one that decides whether this card was actually closed: the message must name repetition, not malformedness. The PR states the reason better than the dispatch did — " Both stale premises were re-verified rather than taken from the claim comment, which is the right handling for a card whose body inverts its own verdict. Two things beyond the ask§4 keeps the reverse verification as tests, not as a transcript. Handed the repetition directly, the real normalizer still answers Throwing instead of responding is the correct deviation, and the reasoning generalizes. Also correct: Ledger discipline held
The three findings are correctly scoped#8001 is the one I want on the record: a repeated #8003 is a sharp catch: the normalizer's Docs correctly not touched: Generated by Claude Code |
hotlong
commented
Aug 12, 2026
One addendum to the review above — not a change request, and not a reason to pull this out of the queue. The docs check in this PR went to
That sentence stays true — this PR does not change malformed-JSON handling. But the page now under-describes the parameter it documents: a second condition answers the same code on the same slot, and it is the condition this PR exists to make legible. Leaving it out reproduces #7390's own gap one layer up — the caller is still not told that sending it twice is the problem. Incomplete, not incorrect, so it is filed as #8005 rather than churning a queued PR for one clause. Worth noting for the drift advisory's own credibility: Verdict unchanged: accept, auto-merge on. Generated by Claude Code |
hotlong
commented
Aug 12, 2026
PM ruling on the scope question raised in the report — gating all four spellings is correct. Do not trim. The report flags this honestly and asks rather than assumes:
"Narrowest shape" modifies the remedy, not the coverage. It was the maintainer distinguishing refuse from the alternatives on the table — last-wins, AND-merge, and the acceptance-surface change that would have narrowed
Deriving #8001 stays unlabeled and ungraded, as filed. One condition answering The behaviour-change risk is stated the way it should be: Verdict unchanged: accept, auto-merge on. Generated by Claude Code |
Fixes#7390
Implements the maintainer ruling recorded on the issue 2026-08-11 — quoted verbatim and untranslated:
What was wrong
Two of the issue description's sections were overtaken by events before this was picked up; both were re-verified on
origin/mainbefore implementing."Not reachable today" had expired.
packages/plugins/plugin-hono-server/src/adapter.ts:202readsc.req.queries() ?? {}since #6878 route 2 landed (PR #7396), so repeated parameters reachfindData's normalizer asstring[]and both shapes in the body were live.The normalizer structurally cannot judge this slot.#7386's arity gate keys off each slot's declared value type, and a filter AST is an array —
["status","=","open"]. The normalizer serves two ingresses it cannot tell apart (GET /data/:object, where a repeat isstring[], andPOST /data/:object/query, whose body is arbitrary JSON), so on this one slotArray.isArrayis not evidence of anything.?filter={"a":1}&filter={"b":2}400 INVALID_FILTER, diagnosed as malformed400 INVALID_FILTER, diagnosed as repetition?filter=status&filter=%3D&filter=open200, applying{status:"open"}400 INVALID_FILTERThe first was the common shape and its message was actively misleading — both filters the caller sent were well-formed, and the response told them to check the AST syntax that was never wrong. The second is contrived to write by hand but is the sharper defect: three occurrences of one parameter happened to spell a valid AST, so the request succeeded while applying a filter nobody expressed.
Where it lands, and why not the obvious place
At the REST querystring parse (
packages/rest), not in the shared normalizer. The querystring ingress is the only layer that knows it is a querystring — and there an array on the filter slot is a repeated parameter and can be nothing else. That keeps the normalizer free of the heuristic ("an array of strings each parseable as JSON is probably a repetition") that #4181 and #4121 spent effort removing, and leaves the body face alone.packages/specdoes not move:INVALID_FILTERis already a standard-catalog code (errors.zod.ts), so no ledger entry is needed either.The shape follows the file's existing one
The gate lives in
query-multiplicity.ts, the module that already owns this rule ([#6877]), and reuses itsreadSingleQueryValuecount-not-shape semantics — so a one-element array is one occurrence and is unwrapped rather than refused.It throws rather than responding, which is the one deliberate difference from its sibling
refuseRepeatedQueryParams. The data routes speak the flatmapDataErrorenvelope, and that is the envelope this route's other filter refusals already arrive in —unusableFilterErrorandmalformedFilterArrayErrorboth throw400/INVALID_FILTERand are shaped by the handler's own catch. Throwing from inside the sametrygives one slot one wire code and one body shape, whether the filter was unreadable or sent twice; responding here would have authored a second dialect for one condition.INVALID_FILTERis already inisExpectedQueryRejection's vocabulary, so the refusal does not print an "[REST] Unhandled error" line.All four wire spellings of the one slot are covered.
where/filterare derived from the spec's ownRPC_QUERY_ALIAS_SLOTS("the ONE place the alias to canonical mapping is declared"), so a spelling added there reaches the gate;filters/$filterare wire-only and named locally, because@objectstack/metadata-protocolis a dev-only dependency and no runtime import of its table exists. That asymmetry is filed as #8002.Pins
packages/rest/src/rest-server-repeated-filter-param.test.ts, 24 cases:statusandcode) and that the message names repetition and does not say "malformed". That third assertion is the point:400+INVALID_FILTERwere both already true of the misdiagnosis, so a status-only test passes green against the bug.?filter=in both accepted forms (JSON object and bare AST) reachesfindDatabyte-identical; a one-element array is unwrapped;$selectkeeps its array arm; a filterless request is untouched.POST /data/:object/querywith a body-form AST (flat and nested) is forwarded, proving the ingress was gated and not the parser.Malformed $filter: unrecognised operator..., and handed the accidental AST directly it still returns the wrong row happily. Those two are the reverse verification kept as tests rather than as a transcript — they document why deleting the gate is not a cleanup.Reverse verification
Guard removed from the committed state, suite re-run: 10 failed / 14 passed, no compile error, and it produced both directions the card allows for.
The old diagnosis, through the real stack:
The accidental 200, through the real engine — the request returning exactly the one
openrow nobody asked to filter on:The mocked-protocol cases fell to
200 {"records":[]}, and the one-element-array case failed asexpected [ '{"status":"open"}' ] to be '{"status":"open"}'— so the unwrap is real work, not a no-op. Guard restored withgit checkoutfrom the commit (nevergit stash); suite green again.Verification
pnpm -w typecheck(turbo,--concurrency=2): 127/127 successful. That whole-workspace run is also the consumer sweep — no single-direction--filterambiguity.@objectstack/restsuite: 95 files / 1555 tests passed.check:type-check-debt:@objectstack/restTEST_DEBT re-measured at 155, exactly its recorded number, with zero errors attributable to the new file. The entry has no margin by design, so a first measurement of 156 (one TS2554 from a one-argumentregisterObject) was fixed, not ledgered.check:nul-bytesOK;check:error-code-casingOK.Not in this PR
content/docs/protocol/**was checked before writing anything:kernel/http-protocol.mdxdocumentsfilteras a single JSON parameter and nothing claims it is multi-valued, so no spec-side prose rides this change.Out-of-scope findings
Filed unassigned per Prime Directive #10, none fixed here:
?filter=answers two different error codes depending on which data route received it #8001 — a repeated?filter=answersINVALID_FILTERon this route andVALIDATION_ERRORonGET /data/:object/export. Both are individually right (this one by ruling, that one by the sharedpackages/rest的其它req.query.*读取点同样把string | string[]当字符串用(#6307 的未扩大部分) #6877 helper); recording the fork rather than choosing for the maintainer.filters/$filter) are now declared in two packages, with no gate reconciling them #8002 —filters/$filterare now declared in two packages with no gate reconciling them.wherearity note still says a repeated?filter=is refused byisFilterASTfailing #8003 — the normalizer'swherearity note still says a repeated?filter=is refused byisFilterASTfailing, which was always describing the defect as the answer and is now stale for this route too.Generated by Claude Code