fix(rest)!: the remaining numeric query reads refuse what they cannot read, held by a census (#20139) - #20345
Conversation
…ase) Per-door pins for the #20139 family members: history sinceSeq, audit limit, diff from/to, search perObject, approvals limit/offset. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
… read, held by a census History sinceSeq and audit limit read through their declarations; diff from/to, search perObject and approvals limit/offset read as whole numbers, all through readDeclaredQueryNumber. A census test classifies every numeric coercion in rest-server.ts so a new bare Number() over a query value reddens its PR. Export page and rollback toVersion are the ledgered exemptions, each pinned. The publish route comment names 404 NO_DRAFT instead of the retired [no_draft] opener. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 2766af584b91a3192d025a9fefff11f49c3d4e4f && git checkout 2766af584b91a3192d025a9fefff11f49c3d4e4f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f d07bbaa797782987170e3864920d278a48c6760c && git checkout -B drift-repro d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f && git merge --no-ff d07bbaa797782987170e3864920d278a48c6760c
node scripts/docs-audit/affected-docs.mjs --json d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the triage direction and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS |
Fixes #20139
Clause-②: no (narrowing)
What
Five published REST doors in
packages/rest/src/rest-server.tsstill read a numeric query parameter with a bareNumber(), and each answered200on a value it could not read. Every one now reads throughreadDeclaredQueryNumber, the private reader PR #20137 added. The reading is against the parameter's own declaration where one exists, and as a whole number otherwise. A census test holds the family closed.GET /meta/:type/:name/historysinceSeqHistoryMetaItemRequestSchema.shape.sinceSeq(z.number().optional())GET /meta/:type/:name/auditlimitAuditMetaItemRequestSchema.shape.limit(z.number().optional())GET /meta/:type/:name/difffrom/to(andfromVersion/toVersion)GET /searchperObjectGET /approvals/requestslimit/offsetThe refusal is
400with the data surface's existingVALIDATION_FAILED+fields[]envelope.fields[0].fieldnames the parameter as the caller spelled it,fields[0].codeisinvalid_type, and the service is never called. Nothing a conforming caller sends changes its answer. There is no new error code and no new export, andpackages/specis untouched.Mechanics:
handleRouteErrorthrough their existing catch, and search reachesmapDataError.500 APPROVAL_REQUEST_LIST_FAILED, so the two reads sit in a scopedtrywhose catch hands the refusal tohandleRouteError. The refusal is the caller's400, not the route's500.Number.isFinitedrops that the reads made dead are gone: historysinceSeq, auditlimit, and the approvalslimit/offset.UNDECLARED_ROW_COUNT_PARAMis nowUNDECLARED_WHOLE_NUMBER_PARAM. The schema is the same (z.number().int().optional()), but it now also reads a row offset and a history version, which "row count" misnamed. Noexportline is added, removed or changed anywhere in the diff.404 [no_draft]now reads404 `NO_DRAFT` when nothing to publish, in the form commit1c8b320aused.Before and after, measured on the real
RestServerroutes"Before" is what the spy service received on
a78f731ad. Every row answered200there.?sinceSeq=abc,?sinceSeq=InfinitysinceSeqdropped: the log read from the start400,sinceSeq?sinceSeq=(empty or blank)sinceSeq: 0forwarded, a cursor nobody sent400?limit=abc,?limit=Infinitylimitdropped: the producer default of 100400,limit?limit=(empty or blank)limit: 0, which the implementation clamps to one event400?from=abc&to=3toVersion: 3sent: "the version before 3" was diffed400,from?to=abc400,to?from=1.5,?from=%20fromVersion: 1.5/0forwarded400?fromVersion=abc,?toVersion=abc400, naming the spelling used?perObject=abcperObject: NaNhanded tosearchAll: no per-object cap400,perObject?perObject=1.5,Infinity, blank0400?limit=abc,?limit=Infinitylimitdropped: the unpaged 500-row list, nototal400,limit?limit=(empty or blank)limit: 0+total: a one-row page400?limit=10&offset=abcoffsetdropped: page 1400,offset?offset=(empty)offset: 0: the service's 50-row paged mode400?limit=1.5,?offset=1.5400Unchanged, pinned by lit controls:
sinceSeqmember, no auditlimitmember, no diff version members,perObjectundefined, and both approvals members undefined with nototal.sinceSeq5/0/1.5;limit25/0/1.5/900;from=2&to=3,fromVersion=1&toVersion=4, andfrom=0;perObject5/0/50;limit=50&offset=0,limit=25&offset=50,limit=0andoffset=-1.fromstill wins overfromVersion, as the old??had it.The empty string, per door. It is decided by the reader's own rule: from what the door answered for
?x=before./diff'sparseV('')and search's falsy guard.Number('')invented a0that changed the answer:sinceSeq: 0, which the SQL repository applies by skipping rows at or belowevent_seq0;The census
packages/rest/src/rest-server-query-number-census.test.tsparsesrest-server.tswith the TypeScript AST and finds every numeric coercion:Number/parseInt/parseFloatcalled ornew-ed, bare or asNumber.parseInt/Number.parseFloat;.map(Number));+.Every site must be in the test's ledger under one of three dispositions:
reader: the oneNumber(raw)insidereadDeclaredQueryNumber;not-a-query-value: the reason names the value's source;exempt: the reason says why a refusal would be wrong, and a test pins it.An unclassified site fails with its line, its key and the two ways to fix it. A ledger row whose site has gone fails too.
It counts every coercion, not "the ones that look like query reads", and the reason is measured: the census found a member the card does not list.
GET /meta/:type/:name/diffreadfrom/towithNumber(raw)inside a localparseVhelper, one frame away fromreq.query, and a pattern such asNumber(req.query.orNumber(q.does not see through that frame. The cost is one ledger row per non-query coercion added to this file. There are 10 coercion sites in about 13,700 lines today.A key is
anchor » coercion text. The anchor is the route (from the registration'smethod+path), or else the enclosing named function; line numbers are not used. An edit elsewhere in the file never moves a key. Editing a ledgered coercion does move its key, on purpose.Census result on this branch: 10 sites, all classified.
function readDeclaredQueryNumber » Number(raw)function importJobToProgress » Number(row?.… ?? 0)(six counters)sys_import_jobcountersGET ${basePath}/forms/:slug/lookup/:field » Number(picker.maxResults)publicPicker.maxResults, declaredz.number().int().min(1).max(50)POST ${metaPath}/:type/:name/rollback » Number(toVersionRaw)GET ${dataPath}/:object/export » Number(q.page)Scope notes:
parseInt,parseFloator unary+today.?id=,?object=, plural/meta/docsbodies, locale) and refuses apublicaudience to anonymous callers #20320's claimant: no numeric query read sits in theGET /meta/:typelist handler (about :5880 to :6300 ona78f731ad). That move needs no census row unless it adds a coercion.rest-server.tsleaves its view.x * 1,Math.trunc(x)).The two exemptions, justified and pinned
Export
?page=staysNumber(q.page) || 500.pagesets only the chunk size of the export's ownfindDataloop, clamped to [50, 5000].pageabsent,abc, empty, blank,1.5,Infinity,-5,50and5000.abc, empty and blank produce the same threefindDatacalls as an absentpage(500, 500, 200).page=50really does change the chunking to 24 calls of 50.pagewidens or substitutes the answer, which is the family's definition. A refusal would turn a correct export into a400.page, absent included, and refusing?page=abcwould not change it. See Acceptance notes.Rollback
toVersionis read body-first (body.toVersion ?? body.version ?? req.query.toVersion) and is then checked, not served.400 INVALID_REQUESTbeforerollbackMetaItemruns. It is pinned forabc, empty andInfinity, with a lit control where3reaches the verb as3.INVALID_REQUEST, and moving it ontoVALIDATION_FAILEDwould be a wire change to a door that already refuses. No card here decides that.PM mechanism assumptions, measured
a78f731ad, through the real routes, before any source edit: all 30 refusal rows answered200, and all 25 lit controls passed. The census then found the list incomplete by one door,/diff, which is fixed here, the same class on the class-closure card. It also found two coercions over a query value that are not family members: rollback and exportpage, ledgered above.a78f731ad: :103 to :111, :670 to :737, :7485 to :7493, :7592 to :7600, :7677 to :7685, :7736 to :7757, :8084 to :8101, :9810 to :9818, :10220 to :10231 and :12777 to :12793. None falls in:STORED_VERSION_DOOR_POLICYdocblock (about :3463 to :3473);?state=draftbranch (about :6921 to :6946);GET /meta/:typelist handler.1c8b320a's form.Tests
All at head
d07bbaa79, after mergingorigin/maind3958bac6(no rebase, no conflict; it toucheddriver-sqland one rest test file, notrest-server.ts).packages/rest/src/rest-server-query-number-reads.test.ts, 55 cases. Each refusal pinsstatus400,codeVALIDATION_FAILED,fields[0].fieldandfields[0].code, and that the service spy was never called. Each lit control pins the argument the service received.54658aa45carries the pins alone):Tests 30 failed | 25 passed (55). Every failure is "expected a 400 refusal … got 200".packages/rest/src/rest-server-query-number-census.test.ts, 14 cases. They cover the walker's recognition of every spelling (and of nothing else), classification, staleness, the single reader, reasons, and both exemption pins.pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2givesTest Files 206 passed (206),Tests 3747 passed | 2 skipped (3749).pnpm --filter @objectstack/rest run test:repogivesTests 8 passed (8).pnpm --filter @objectstack/rest run typecheckexits 0, andcheck:test-typecheck: OK — … 0 file(s) / 0 error(s), so both new test files compile in the test-layer program.Ablations
The fix was committed first (
5ef104dd1). Each leg ran throughscripts/ablation-replace.mjsin WRAP mode, which carries an EXIT/INT/TERM restore. Before the tests ran, the wrapped command proved the mutation was on disk with a marker count. Vitest readsrest-server.tsthrough the relative import and the census reads it from disk, so nodist/is involved.perObjectback toreq.query?.perObject ? Number(req.query.perObject) : undefined9505bebc→0b580156/diffback to the oldparseVhelper85ca1f0econst extra = Number(req.query.extra);in the search handler1d6d2ea59505bebcequals HEAD's,git diff HEADis empty, and the marker count is back to 0.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 62 commands from merge based3958bac6. All were run atd07bbaa79with exit codes captured before any pipe. The--ranreconciliation reads62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN.check:adr-0087-registration --base origin/main,check:changeset-no-major --base origin/main,check:empty-changeset --base origin/main,check:changeset-gate-self-tests;check:route-envelope, which stays green: the approvals refusal goes throughhandleRouteErrorand adds no dialect body;check:cross-package-test-inputs, which stays green: the census reads a file in its own directory;check:nul-bytes,check:doc-authoring,check:published-files,check:undeclared-dep-imports(typescriptis a declared devDependency of rest),check:test-source-alias,check:type-check-coverageandcheck:issue-citations.check:dual-build-cjs-loads. Exit 3PREREQUISITE NOT MET, because 43 workspace packages have nodist/. Declared narrowing: afterpnpm --filter @objectstack/rest build(check-dts-emitted: 2/2),require('./dist/index.cjs').RestServerandimport('@objectstack/rest')both answerfunction.check:type-check-debt. Exit 3PREREQUISITE NOT MET, because the full closure is not built. Rest's own typecheck is green above, and the diff moves no export. CI builds the closure before this step.pnpm lint(this lane's addition) ran as the full union,eslint . --no-inline-config, atd07bbaa79: exit 0.node scripts/check-issue-citations.mjs --base origin/mainafter the merge exits 0, withcitations judged: 13 … 13 resolves.First-party callers, measured
@objectstack/clientsends each of these parameters asString(number):getHistorysinceSeq,getAuditlimit,diffItemfrom/to,searchperObject.approvals.listRequestssends nolimit/offset.f8a9d0fb:limit: 50withoffset: 0, orrows.length.metadata-clientdiff/historysend numbers.None sends a value this PR refuses.
Acceptance notes
rest-server.tsand outside both fences; each item is named above:/diffdoor's two reads, the class member the census found;UNDECLARED_WHOLE_NUMBER_PARAM's rename and docblock, which touch the export and searchlimitcall lines by name only;readDeclaredQueryNumber's docblock (no body change);AuditMetaItemRequestSchema.toVersionrefuses with the text "'toVersion' (positive integer) is required", but?toVersion=1.5passes itsisFinite/ below-1 check and reachesrollbackMetaItemas 1.5. Read only, not measured. It is outside this family, since nothing is dropped or substituted before a check. Carrier: none.?page=reads like a page number but is a chunk size.?page=2&limit=100exports rows 1 to 100 in chunks of 50, not "page 2". That is naming, readable values are outside this family, and it is noted, not filed. Carrier: none.page) can decide the header when no security service is reachable. This is pre-existing and documented at security 服务 getReadableFields 查询面(export 列投影的长期正解) #3547, and it is independent of whetherpageis readable. Read only. Carrier: none./diff:fromsilently wins overfromVersionwhen both are sent, as before. The precedence is preserved, not judged.sinceSeqand auditlimitadmit fractions and negatives by declaration, and the diff versions admit0and negatives. That is the spec seat's question.not-a-query-valuerows are judged once, by a person. A future edit that routes a query value through one of those expressions changes its key and reddens the census, which forces a re-judgement.Clause-②line. The claim's line reads bareClause-②: no, while its prose namesno (narrowing)for the changeset. This body and.changeset/20139-rest-query-number-census.mdcarryno (narrowing), per the dispatch order and PR fix(rest): refuse a ?limit= the door cannot read on import jobs, export, meta history and search (#20061, #20062) #20137's form.Test Coreshards,Dogfood,Build Core,Temporal Conformance, and the workspace type-check lanes.Generated by Claude Code