Uh oh!
There was an error while loading. Please reload this page.
feat(rest): closed query-parameter sets become ingress policy, first tier of data read routes (#7606) - #8044
Conversation
…tier of data read routes (#7606) Handlers read the query keys they know and ignore the remainder, so a misspelled or invented parameter is silently dropped and the caller gets a plausible-looking 200. The failure is undetectable from the response in both directions — a dropped filter widens to everything, a dropped key inside a filter narrows to zero — and an AI caller can see neither. Policy (maintainer ruling, 2026-08-12): a route declares its closed query parameter set on the day it lands, refusing an unrecognised name with a located 400. Adoption is incremental, per lane, data read routes first — never a one-shot sweep. Written up in `query-allowlist.ts` and as rule 5 of AGENTS.md's "Route & surface ownership", so it is enforceable at review. First tier, each set measured from the handler's own read points: GET /data/:object/:id select, expand GET /data/:object/export format, header, limit, page, filter, search, searchFields, orderby, fields, locale GET /search q, query, objects, limit, perObject `locale` on the export route is read one frame down, by `extractLocale` behind `translateMetaItem` — invisible in the handler body, and the one name a read of the handler alone gets wrong. Omitting it would have 400'd every localised export that works today. GET /data/:object is deliberately NOT closed: its handler passes the whole query to the normalizer, which lowers every leftover key into an implicit field-equality predicate, so the valid names are the object's own fields. It is already gated one layer down by #4134/#7534's unknown-FIELD refusal. #7390's repeated-filter INVALID_FILTER refusal there is untouched, and since recognition never runs on that route the two guards never meet on one request — #8001's fork is neither widened nor resolved. Composition: recognition runs before the arity gate, both answering the same nested ADR-0112 VALIDATION_ERROR, so no route gains a second dialect. Tests pin both halves per route (#7527's file being the template): refusal (status + nested error.code + the service was never called) beside preservation (the arguments the service actually received), plus the composition order and the exclusion. Filed #8039 for an out-of-scope finding: the by-id route folds no query aliases, so the canonical `fields` spelling is dropped while the alias `select` works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgfwFqbv6D8knYH32yaqva
…sed-query-param-ingress-policy
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:
|
…t issue-ID citations
`check:pm-skill-id-lint` gates AGENTS.md against `/#[0-9]{3,}/`: operative
agent-protocol text carries lessons in full — failure mode, discipline,
boundary — because an issue-ID citation invites the reader to dereference
history, which costs more than it returns. Rule 5 arrived with three.
Both now say the thing instead of pointing at it: the two-pin requirement
states why neither half is optional (a bare status assertion is not a pin —
"still 200" is what the defect looked like), and the list-route exclusion
names the refusal it defers to (`400 INVALID_FIELD`, judged against the
registry's real field map, injected columns included) rather than the cards
that landed it.
Maintainer-ruling provenance is unaffected — date and verbatim quote carry no
number and stay as they were.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgfwFqbv6D8knYH32yaqvahotlong
commented
Aug 12, 2026
PM review —
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7606
Implements the maintainer ruling of 2026-08-12: policy YES, incremental, ⛔ no big-bang wave. Scope is the ruling's first batch — the policy in writing, plus the first tier of data read routes.
The policy, in writing
A REST route declares its closed query-parameter set on the day it lands, opening its handler with
refuseUnknownQueryParams(req, res, <EXPORTED_PARAMS>)so an unrecognised name gets a located400instead of being dropped.It is written in two places, both review-enforceable:
packages/rest/src/query-allowlist.ts— the module header, where an implementer lands. Carries the ruling verbatim, the three measuring constraints, the exclusion rule, and the composition order.The first tier — each closed set MEASURED, and where from
GET /data/:object/:idselect,expandconst { select, expand } = req.query || {}. The handler destructures exactly these two and forwards nothing else.GET /data/:object/exportformat,header,limit,page,filter,search,searchFields,orderby,fields,localeq = req.query ?? {}in the handler body — pluslocale, read one frame down byextractLocalebehind thetranslateMetaItemcall that localises the header row.GET /searchq,query,objects,limit,perObjectSets are exported constants (
DATA_RECORD_READ_PARAMS,DATA_EXPORT_PARAMS,GLOBAL_SEARCH_PARAMS) so the pin tests assert against what the route actually declares, never a hand-copied second list.localeis the measurement that nearly went wrongIt appears nowhere in the export handler's body. A set measured from the handler alone would have omitted it and
400'd every?locale=zh-CNexport that works today — silent widening traded for a loud outage, committed by the change meant to prevent it. It was found by reading the helpers the handler calls. That lesson is now in the AGENTS.md rule and pinned by name in the tests.Middleware was swept for query reads before measuring:
resolveProtocol,resolveExecCtx,enforceAuth,enforceApiAccess,enforceExportPermission,resolveSecurityServiceandresolveRequestEnvironmentIdread none (environment resolution is by host/header).translateMetaItemwas the only hit.⛔ What is deliberately NOT closed
GET /data/:object(the record list) does not get this gate, and the reason is recorded in code so the next agent chooses rather than discovers. Its handler passes the whole query record tofindData, whose normalizer lowers every leftover key into an implicit field-equality predicate —?status=openis the filter. The valid names are the object's own fields, which vary per object and include the audit/tenant/owner columns the registry injects, so a closed list here could only ever be wrong. It is already gated one layer down and against the right authority: #4134 refuses an unknown field with400 INVALID_FIELD, and #7534 extended that to the explicitwhere/$filteraxes.The general test, now written down: if an unrecognised name has a defined meaning on the route, the set is open — gate it where the authority for the name lives.
Three tests pin the exclusion, including one that goes red if someone "completes the sweep".
How the two guards compose
Recognition runs before the arity gate, per the rule
query-allowlist.tsalready stated (#7527): "I do not know this parameter" outranks "this parameter I do know was supplied twice", so a request committing both errors is told the more fundamental one. Pinned on both tiered routes rather than left to the order the calls happen to sit in.Both answer the same envelope — nested ADR-0112
{ error: { code: 'VALIDATION_ERROR', message } }— so composing them adds no second dialect to any route. Also pinned.⛔ #8001 is not resolved here, and no code fork is forced
The serial constraint from PR #8004 (#7390) does not bite, because the two gates never meet on one request:
assertFilterParamSuppliedOnceanswers400 INVALID_FILTERthrough the flatmapDataErrorenvelope and lives only on the list route excluded above, which never gets recognition.On the export route,
filteris inside the closed set, so a repeated?filter=passes recognition and still reaches the multiplicity gate, still answering exactly what it answered before. The #8001 divergence is neither widened nor resolved — a test asserts this explicitly and goes red if a later change makes that call unilaterally.Tests
packages/rest/src/rest-server-closed-query-params.test.ts— 27 tests, both halves per route on the #7527 template:error.code+ the located message + the service was never called.200, because "still 200" is exactly what the defect looked like.limitandpageon the export route are separated by which one binds the chunk ($top= 25 vs 50), so each is proved to have arrived.Reverse-verified from the committed state: with the three gate calls removed, 11 refusal/composition tests go red and the preservation pins stay green. The by-id case fails as
200with the full record — the silent-widening defect, measured:Gates
pnpm -w typecheck— 127/127 tasks pass@objectstack/restsuite — 98 files, 1593 tests pass (re-run after mergingmain)check:type-check-debt—@objectstack/restholds at 155, not raised; it is absent from the surplus list, i.e. still exactly at its zero-margin ceilingcheck:adr-0087-registration— the changeset carries its disposition--no-inline-config, the repo's own flag)packages/specmoved onmainwhile this was open → rebuilt,check:generatedreports all 13 artifacts currentBreaking tolerated traffic is deliberate
Stated plainly in the changeset rather than described as a bug fix: a caller sending these routes a parameter we ignore today starts getting a
400, the blast radius cannot be measured from our side precisely because we have been dropping it silently, and v17 is the intended window.The two callers most likely to notice are on
GET /data/:object/:id:?fields=and?populate=are refused. They are the spec alias table's canonical/alias spellings for slots this route reads asselect/expand, and it folds no aliases — so they were being dropped, silently returning the full record. They are left outside the closed set rather than implemented, because adding them would advertise a capability the handler does not have; the refusal namesselect/expandas what the route accepts.Out-of-scope finding filed
#8039 —
GET /data/:object/:idfolds no query aliases, so the canonicalfieldsspelling is dropped while the aliasselectworks, diverging fromRPC_QUERY_ALIAS_SLOTSand from the sibling list route. Filed unassigned per Prime Directive #10, linked from the code comment.Notes for review
#7981(convergingregisterSecurityEndpoints' envelopes) is in flight on the same file but a different region;mainwas merged rather than resolved blind, and the merge was clean.content/docs/releases/is untouched.Generated by Claude Code