Repository navigation
Description
Found while implementing #7390 (PR pending). Filed unassigned per Prime Directive #10; not fixed there, because #7390's ruling names the code for its own route and the sibling route is a different surface.
The divergence
Two sibling routes in packages/rest/src/rest-server.ts both refuse a repeated ?filter=, and they answer differently:
| route | code | envelope | gate |
|---|---|---|---|
GET /data/:object |
INVALID_FILTER |
flat { error, code, object } (mapDataError) |
assertFilterParamSuppliedOnce (#7390) |
GET /data/:object/export |
VALIDATION_ERROR |
nested { error: { code, message } } |
refuseRepeatedQueryParams (#6877) |
So ?filter=a&filter=b is one caller mistake with two machine-readable answers, decided by which path it was sent to. A client that branches on error.code has to know both.
Why each is currently right on its own terms
Neither is an oversight, which is why this is a fork rather than a bug with an obvious fix:
- The export route's answer comes from the shared
#6877helper, whose whole point is that every route answers the same multiplicity rule identically.VALIDATION_ERRORispackages/rest's house catalog member for a 400. - The list route's answer is a maintainer ruling (2026-08-11, on finding: a repeated
?filter=onGET /data/:objectcannot be told from a filter AST, so it is diagnosed as a malformed filter (and, rarely, succeeds) #7390):400 INVALID_FILTERwith a message naming repetition. It also matches that route's own family —unusableFilterErrorandmalformedFilterArrayErrorboth reach the wire asINVALID_FILTERthroughmapDataError, so a repeated filter there is one of three ways the same slot fails, all with one code and one body shape.
Both rules are "one condition, one answer" — they just draw the condition's boundary differently. The list route reads it as the filter slot failed; the export route reads it as a single-valued parameter was repeated.
The options, if this is worth closing
- Move the export route's filter slot onto the same gate — repeated
filterthere becomesINVALID_FILTERtoo, and the other export parameters (format,limit,page,orderby,search,header) keepVALIDATION_ERROR. Consistent per-slot; splits the export route's own answer across two codes. - Leave it. The boundary really is per-route-family, and each envelope matches the family its route already speaks.
- Move the list route onto
VALIDATION_ERROR— consistent per-condition, but it contradicts the finding: a repeated?filter=onGET /data/:objectcannot be told from a filter AST, so it is diagnosed as a malformed filter (and, rarely, succeeds) #7390 ruling and would give one slot two codes on one route.
Not obviously worth paying for; recording it so the next person to touch either gate is choosing rather than discovering.
Dedup
Searched open issues for repeated-filter / VALIDATION_ERROR / INVALID_FILTER / error-code-inconsistency wording, and for the query-multiplicity and refuseRepeatedQueryParams identifiers. Hits: #7390 (the parent this came out of), #7534, #4436, #3948 — none covers the cross-route code divergence.
Generated by Claude Code
Activity
hotlong commentedon Aug 12, 2026
Triage: graded
finding(held for the findings round), routeddomain:cli(both routes live inpackages/rest).Rationale: real but low-stakes cross-route divergence, and each side is individually correct by its own rule — the list route by the 2026-08-11 maintainer ruling on #7390, the export route by the shared #6877 helper. Nothing a user hits today beyond a confusing error-code split for one caller mistake. If promoted later, option 1 (move the export route's filter slot only onto the
INVALID_FILTERgate) is the only shape that does not contradict the standing ruling; option 3 is foreclosed by it.Generated by Claude Code
hotlong commentedon Aug 12, 2026
Finding-grading round: closed, not planned.
Premise re-verified (both gates live:
query-multiplicity.ts:127/:240; list route calls the filter gate at rest-server.ts:6870), but each side is correct by its own standing rule — list = the 2026-08-11 maintainer ruling on #7390, export = the shared #6877 helper — and option 3 is foreclosed by that ruling. Option 1 would fix a cross-route cosmetic by splitting the export route's own answer across two codes, which is locally worse. The filer's own "not obviously worth paying for" stands. Sibling divergence #8039 escalated separately.Generated by Claude Code
feat(rest): closed query-parameter sets become ingress policy, first …
docs(qa): P0 sweep follow-through — re-point query-contract-matrix, c…