Skip to content

fix(rest)!: the remaining numeric query reads refuse what they cannot read, held by a census (#20139) - #20345

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20139-rest-query-number-census
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20139-rest-query-number-census

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20139
Clause-②: no (narrowing)

What

Five published REST doors in packages/rest/src/rest-server.ts still read a numeric query parameter with a bare Number(), and each answered 200 on a value it could not read. Every one now reads through readDeclaredQueryNumber, 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.

door parameter now read through
GET /meta/:type/:name/history sinceSeq HistoryMetaItemRequestSchema.shape.sinceSeq (z.number().optional())
GET /meta/:type/:name/audit limit AuditMetaItemRequestSchema.shape.limit (z.number().optional())
GET /meta/:type/:name/diff from / to (and fromVersion / toVersion) a whole number: no declared request schema
GET /search perObject a whole number
GET /approvals/requests limit / offset a whole number

The refusal is 400 with the data surface's existing VALIDATION_FAILED + fields[] envelope. fields[0].field names the parameter as the caller spelled it, fields[0].code is invalid_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, and packages/spec is untouched.

Mechanics:

  • Existing catches. History, audit and diff reach handleRouteError through their existing catch, and search reaches mapDataError.
  • Approvals. Its catch answers every throw with 500 APPROVAL_REQUEST_LIST_FAILED, so the two reads sit in a scoped try whose catch hands the refusal to handleRouteError. The refusal is the caller's 400, not the route's 500.
  • Dead drops removed. The Number.isFinite drops that the reads made dead are gone: history sinceSeq, audit limit, and the approvals limit / offset.
  • Renamed constant. The module-private UNDECLARED_ROW_COUNT_PARAM is now UNDECLARED_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. No export line is added, removed or changed anywhere in the diff.
  • Reader docblock. The refusal paragraph now names the approvals door's scoped catch, and a new section points at the census. The function body is unchanged.
  • Rider. The publish route comment 404 [no_draft] now reads 404 `NO_DRAFT` when nothing to publish, in the form commit 1c8b320a used.

Before and after, measured on the real RestServer routes

"Before" is what the spy service received on a78f731ad. Every row answered 200 there.

door request before now
history ?sinceSeq=abc, ?sinceSeq=Infinity sinceSeq dropped: the log read from the start 400, sinceSeq
history ?sinceSeq= (empty or blank) sinceSeq: 0 forwarded, a cursor nobody sent 400
audit ?limit=abc, ?limit=Infinity limit dropped: the producer default of 100 400, limit
audit ?limit= (empty or blank) limit: 0, which the implementation clamps to one event 400
diff ?from=abc&to=3 only toVersion: 3 sent: "the version before 3" was diffed 400, from
diff ?to=abc no bound sent: the current body was diffed 400, to
diff ?from=1.5, ?from=%20 fromVersion: 1.5 / 0 forwarded 400
diff ?fromVersion=abc, ?toVersion=abc dropped 400, naming the spelling used
search ?perObject=abc perObject: NaN handed to searchAll: no per-object cap 400, perObject
search ?perObject=1.5, Infinity, blank 1.5 / clamped to 25 / 0 400
approvals ?limit=abc, ?limit=Infinity limit dropped: the unpaged 500-row list, no total 400, limit
approvals ?limit= (empty or blank) limit: 0 + total: a one-row page 400
approvals ?limit=10&offset=abc offset dropped: page 1 400, offset
approvals ?offset= (empty) offset: 0: the service's 50-row paged mode 400
approvals ?limit=1.5, ?offset=1.5 1.5 handed to the engine 400

Unchanged, pinned by lit controls:

  • Absent means what it meant: no sinceSeq member, no audit limit member, no diff version members, perObject undefined, and both approvals members undefined with no total.
  • Conforming values reach the service as before, including the ranges no card here takes a position on:
    • history sinceSeq 5 / 0 / 1.5;
    • audit limit 25 / 0 / 1.5 / 900;
    • diff from=2&to=3, fromVersion=1&toVersion=4, and from=0;
    • search perObject 5 / 0 / 50;
    • approvals limit=50&offset=0, limit=25&offset=50, limit=0 and offset=-1.
  • Precedence. from still wins over fromVersion, 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.

  • Stays absent where that already was the absent answer: /diff's parseV('') and search's falsy guard.
  • Is refused where Number('') invented a 0 that changed the answer:
    • history's sinceSeq: 0, which the SQL repository applies by skipping rows at or below event_seq 0;
    • audit's one-event clamp;
    • approvals' one-row page, or the 50-row paged mode that absent does not trigger.

The census

packages/rest/src/rest-server-query-number-census.test.ts parses rest-server.ts with the TypeScript AST and finds every numeric coercion:

  • Number / parseInt / parseFloat called or new-ed, bare or as Number.parseInt / Number.parseFloat;
  • one of them passed or assigned as a value (.map(Number));
  • unary +.

Every site must be in the test's ledger under one of three dispositions:

  • reader: the one Number(raw) inside readDeclaredQueryNumber;
  • 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/diff read from / to with Number(raw) inside a local parseV helper, one frame away from req.query, and a pattern such as Number(req.query. or Number(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's method + 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.

site disposition
function readDeclaredQueryNumber » Number(raw) reader
function importJobToProgress » Number(row?.… ?? 0) (six counters) not-a-query-value: persisted sys_import_job counters
GET ${basePath}/forms/:slug/lookup/:field » Number(picker.maxResults) not-a-query-value: stored form metadata publicPicker.maxResults, declared z.number().int().min(1).max(50)
POST ${metaPath}/:type/:name/rollback » Number(toVersionRaw) exempt
GET ${dataPath}/:object/export » Number(q.page) exempt

Scope notes:

The two exemptions, justified and pinned

Export ?page= stays Number(q.page) || 500.

  • What it does. page sets only the chunk size of the export's own findData loop, clamped to [50, 5000].
  • Rows are unchanged. Measured on the real route with 1200 rows, the streamed body is byte-identical for page absent, abc, empty, blank, 1.5, Infinity, -5, 50 and 5000.
  • Unreadable reads as absent. abc, empty and blank produce the same three findData calls as an absent page (500, 500, 200).
  • The pin is live. page=50 really does change the chunking to 24 calls of 50.
  • The call. No value of page widens or substitutes the answer, which is the family's definition. A refusal would turn a correct export into a 400.
  • Caveat. One place lets chunk size reach the output: the 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391 masked-row header fallback. When no security service is reachable, it infers readable columns from whatever the first chunk holds. That is a property of the fallback for every page, absent included, and refusing ?page=abc would not change it. See Acceptance notes.

Rollback toVersion is read body-first (body.toVersion ?? body.version ?? req.query.toVersion) and is then checked, not served.

  • Already refused. A non-finite result, or one below 1, is refused 400 INVALID_REQUEST before rollbackMetaItem runs. It is pinned for abc, empty and Infinity, with a lit control where 3 reaches the verb as 3.
  • Outside the family. Nothing is dropped or substituted.
  • Not moved to the reader. Its refusal is the door's own INVALID_REQUEST, and moving it onto VALIDATION_FAILED would be a wire change to a door that already refuses. No card here decides that.

PM mechanism assumptions, measured

  1. The sites. The five listed doors were confirmed red on a78f731ad, through the real routes, before any source edit: all 30 refusal rows answered 200, 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 export page, ledgered above.
  2. The fences. No hunk touches them. Measured as the diff's hunks against 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:
    • the STORED_VERSION_DOOR_POLICY docblock (about :3463 to :3473);
    • the plain read's ?state=draft branch (about :6921 to :6946);
    • the GET /meta/:type list handler.
  3. The rider. Done, in commit 1c8b320a's form.

Tests

All at head d07bbaa79, after merging origin/main d3958bac6 (no rebase, no conflict; it touched driver-sql and one rest test file, not rest-server.ts).

  • New per-door pins: packages/rest/src/rest-server-query-number-reads.test.ts, 55 cases. Each refusal pins status 400, code VALIDATION_FAILED, fields[0].field and fields[0].code, and that the service spy was never called. Each lit control pins the argument the service received.
    • Against the unfixed source (commit 54658aa45 carries the pins alone): Tests 30 failed | 25 passed (55). Every failure is "expected a 400 refusal … got 200".
  • New census: 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.
  • Rest suite: pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 gives Test Files 206 passed (206), Tests 3747 passed | 2 skipped (3749). pnpm --filter @objectstack/rest run test:repo gives Tests 8 passed (8).
  • Typecheck: pnpm --filter @objectstack/rest run typecheck exits 0, and check: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 through scripts/ablation-replace.mjs in 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 reads rest-server.ts through the relative import and the census reads it from disk, so no dist/ is involved.

leg mutation on disk result
A search perObject back to req.query?.perObject ? Number(req.query.perObject) : undefined anchor 1 → 0; marker count 1; blob 9505bebc → 0b580156 `Tests 5 failed
A′ /diff back to the old parseV helper anchor 1 → 0; marker count 1; blob → 85ca1f0e `Tests 10 failed
B a new const extra = Number(req.query.extra); in the search handler anchor 1 → 0; marker count 1; blob → 1d6d2ea5 `Tests 1 failed
  • Restores proven. Every restore was proven by the tool and again by hand: blob 9505bebc equals HEAD's, git diff HEAD is empty, and the marker count is back to 0.
  • The first B attempt was a no-op. Its replacement contained its own anchor, so the tool refused (anchor count 1 → 1), restored, and no test ran. It was re-anchored on a comment line and re-run; that run is the one reported.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 62 commands from merge base d3958bac6. All were run at d07bbaa79 with exit codes captured before any pipe. The --ran reconciliation reads 62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN.

  • 60 exit 0. These include:
    • 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 through handleRouteError and 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 (typescript is a declared devDependency of rest), check:test-source-alias, check:type-check-coverage and check:issue-citations.
  • NOT MEASURED: check:dual-build-cjs-loads. Exit 3 PREREQUISITE NOT MET, because 43 workspace packages have no dist/. Declared narrowing: after pnpm --filter @objectstack/rest build (check-dts-emitted: 2/2), require('./dist/index.cjs').RestServer and import('@objectstack/rest') both answer function.
  • NOT MEASURED: check:type-check-debt. Exit 3 PREREQUISITE 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, at d07bbaa79: exit 0.
  • Board check: node scripts/check-issue-citations.mjs --base origin/main after the merge exits 0, with citations judged: 13 … 13 resolves.

First-party callers, measured

  • @objectstack/client sends each of these parameters as String(number): getHistory sinceSeq, getAudit limit, diffItem from / to, search perObject. approvals.listRequests sends no limit / offset.
  • objectui at its pin f8a9d0fb:
    • The Console approvals inbox sends limit: 50 with offset: 0, or rows.length.
    • metadata-client diff / history send numbers.
    • The badge and record-panel approvals reads send no paging.

None sends a value this PR refuses.

Acceptance notes

  • File surface beyond the claim's list. All of it is in rest-server.ts and outside both fences; each item is named above:
    • the /diff door's two reads, the class member the census found;
    • UNDECLARED_WHOLE_NUMBER_PARAM's rename and docblock, which touch the export and search limit call lines by name only;
    • readDeclaredQueryNumber's docblock (no body change);
    • the import line for AuditMetaItemRequestSchema.
  • Rollback toVersion refuses with the text "'toVersion' (positive integer) is required", but ?toVersion=1.5 passes its isFinite / below-1 check and reaches rollbackMetaItem as 1.5. Read only, not measured. It is outside this family, since nothing is dropped or substituted before a check. Carrier: none.
  • Export ?page= reads like a page number but is a chunk size. ?page=2&limit=100 exports 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.
  • The 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391 masked-row header fallback infers readable columns from the first chunk, so which rows that chunk holds (and therefore 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 whether page is readable. Read only. Carrier: none.
  • /diff: from silently wins over fromVersion when both are sent, as before. The precedence is preserved, not judged.
  • Bounds. No position on them, as with PR fix(rest): refuse a ?limit= the door cannot read on import jobs, export, meta history and search (#20061, #20062) #20137: history sinceSeq and audit limit admit fractions and negatives by declaration, and the diff versions admit 0 and negatives. That is the spec seat's question.
  • The census's not-a-query-value rows 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.
  • The Clause-② line. The claim's line reads bare Clause-②: no, while its prose names no (narrowing) for the changeset. This body and .changeset/20139-rest-query-number-census.md carry no (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.
  • CI-owned, not run locally: the full Test Core shards, Dogfood, Build Core, Temporal Conformance, and the workspace type-check lanes.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 8 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via /data/:object/export (route, a path literal in a comment on a changed line))
  • content/docs/permissions/permission-sets.mdx (via /data/:object/export (route, a path literal in a comment on a changed line))
  • content/docs/upgrading.mdx (via fromVersion (literal, a string literal on a changed line), toVersion (literal, a string literal on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via /meta/:type/:name/diff (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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): node scripts/docs-audit/affected-docs.mjs --json d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2766af584b91a3192d025a9fefff11f49c3d4e4f — the merge of head d07bbaa797782987170e3864920d278a48c6760c into base d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d07bbaa797782987170e3864920d278a48c6760c

① Derived judgments

  • Declared reads use their own schema. Correct.
    • /history sinceSeq reads HistoryMetaItemRequestSchema.shape.sinceSeq (packages/spec/src/api/protocol.zod.ts:1533, z.number().optional()).
    • /audit limit reads AuditMetaItemRequestSchema.shape.limit (:1426, z.number().optional()).
    • Both go through readDeclaredQueryNumber with emptyIsAbsent: false (rest-server.ts at head, :7514 and :7715).
  • Undeclared reads are whole numbers. Correct. /diff from/to (:8145–:8148), /search perObject (:10281) and /approvals/requests limit/offset (:12855–:12859) all read UNDECLARED_WHOLE_NUMBER_PARAM = z.number().int().optional() (:691). Its initializer is unchanged under the rename.
  • The empty string, per door, against the old answer. Correct.
    • History: the old Number('') was 0, forwarded as sinceSeq: 0. sys-metadata-repository.ts:1207 applies that as "skip rows whose event_seq ?? 0 is at or below 0", and the in-memory repo applies it as since = sinceSeq ?? -1. So the old answer was not the absent answer, and refusing is consistent with the reader's rule.
    • Audit: 0 clamps to one event, against the absent default of 100 (protocol.zod.ts:1427).
    • Approvals: limit: 0 gives Math.max(0,1), a one-row page plus total. offset: 0 alone flips the service into its paged 50-row mode (plugin-approvals/src/approval-service.ts:104-109), against the unpaged 500.
    • /diff and search keep empty as absent (emptyIsAbsent: true): /diff's parseV('') was undefined, and search had a falsy guard.
  • Negatives, 0 on offset, and large values.
    • z.number() admits negatives and fractions on history and audit (pinned: 0, 1.5, 900). These are pinned as reaching the service: offset=-1, limit=0, limit=50&offset=0 and from=0.
    • Infinity is refused on every door, because zod 4 refuses non-finite values. Before, it was dropped or clamped. Every such row is in the PR's tables.
    • Integers beyond Number.MAX_SAFE_INTEGER on the five whole-number parameters are refused by zod 4.6.1's safe-int int(), and they are NOT in the tables. That is not a defect of this PR:
    • Note, unmeasured (no node_modules on this host): if zod reports that check with origin: 'int', zodIssuesToFields (zod-issues-to-fields.ts:78-80) maps it to max_length, not max_value. That is pre-existing since fix(rest): refuse a ?limit= the door cannot read on import jobs, export, meta history and search (#20061, #20062) #20137, and its carrier is the spec seat's D3 mapper.
  • Census completeness. Correct for the card's ask ("every Number( read"), with the listed spellings noted.
    • What the walker counts (rest-server-query-number-census.test.ts):
      • any Call or New whose callee text is Number, parseInt, parseFloat, Number.parseInt or Number.parseFloat;
      • unary +;
      • those identifiers used as values.
    • How it catches the forms asked about:
      • a new Number(req.query.x) is unclassified, so it goes red (walker test 1; ablation B);
      • parseInt(q.x, 10) matches on callee text;
      • an alias (const toN = Number) matches on the identifier-as-value arm;
      • a destructured query value is counted regardless of its argument;
      • a helper one frame away is covered, because anchorOf walks to the enclosing {method, path, handler} literal (walker test 2 pins parseV).
    • Measured on head: exactly 10 Number( call sites (:767, :975, :976, :987–:990, :8061, :9870, :10991), and no parseInt, parseFloat, unary + or z.coerce. That equals the ledger's 10 rows.
    • Not trivially satisfiable by accident: a stale row and an unledgered site both fail, and each site appears exactly once catches a copied ledgered read.
    • Satisfiable only by a deliberate mis-ledger: a not-a-query-value row with a 61-char reason. An exempt row is also not mechanically tied to its pin. Both are review matters by design.
    • Gaps by construction: z.coerce.number(), globalThis.Number, Number.call, and arithmetic coercion.
  • The not-a-query-value rows are real. Correct. importJobToProgress(row) maps a persisted sys_import_job row (:974). picker = fieldCfg?.publicPicker is stored form metadata (:10873).
  • The export ?page= exemption is real. Correct, with a declared caveat.
    • chunkSize (:9870, Number(q.page) || 500, clamped to [50, 5000]) feeds only take = Math.min(chunkSize, limit - exported) (:10058). abc, empty and blank all collapse to 500, which is identical to absent.
    • limit interplay: a chunk never exceeds limit, and X-Export-Limit is independent of page.
    • The 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391 fallback (:10100–:10125) narrows a schema-derived header to the keys present in the first chunk, when !readableProjected && fieldsFromSchema. So chunk size can change the header for heterogeneous rows. That holds for readable page values too, and the PR body declares it.
    • The pin's "byte-identical" claim is measured on homogeneous {id, n} rows only, so the claim is slightly over-general; the caveat is declared.
  • The rollback toVersion exemption is real. Correct. Number(toVersionRaw) (:8061) is followed by !Number.isFinite(toVersion) || toVersion < 1, which gives 400 INVALID_REQUEST before rollbackMetaItem runs, and refuseRepeatedQueryParams(['toVersion']) sits ahead of it. It is refused before the verb.
  • The approvals scoped try. Correct.
    • The try wraps only the two reads. Its catch calls handleRouteError(res, refusal) (error-response.ts:2703). The resolveErrorResponse branch at :1152-1161 answers 400 { error, code: 'VALIDATION_FAILED', fields } for error.code === 'VALIDATION_FAILED', with no object member.
    • svc.listRequests sits after the try, and the pins assert that listRequests and countRequests are not called.
    • refuseUnknownQueryParams and refuseRepeatedQueryParams([... 'limit', 'offset']) run first, so an array never reaches the reader.
    • Every other throw still falls to the outer catch, which is unchanged: 500 APPROVAL_REQUEST_LIST_FAILED.
  • The /diff door. Correct.
    • fromParam = req.query?.from != null ? 'from' : 'fromVersion' is the ?? nullish set, so from wins over fromVersion exactly as before. That includes from='', which is treated as absent with the alias ignored, the same as '' ?? x.
    • Valid whole numbers reach diffMetaItem unchanged (pinned: 2/3, 1/4, 0, and from over fromVersion).
    • The narrowing against the old parseV: fractions and non-finite values are now refused, and both are in the tables.
    • Its hunk (old :8084–:8101) is outside PR fix(rest): the draft read serves an app whole to whoever may save it, and pruned to everyone else #20337's hunks.
  • The rename and the docblocks are behaviour-neutral. Correct.
    • UNDECLARED_ROW_COUNT_PARAM → UNDECLARED_WHOLE_NUMBER_PARAM is a module-private const with the same initializer. Its two call sites change by name only, and there is no export line in the diff.
    • readDeclaredQueryNumber's hunk (-721,17) is comment-only; the body is unchanged.
    • [no_draft] → `NO_DRAFT` is a comment.

② Semver level

minor + Clause-②: no (narrowing) + a BREAKING banner + the adr-0087: not-required (no-migration-prescription) marker is correct.

  • AGENTS.md Post-Task step 3 says (narrowing) is BREAKING. check-changeset-no-major refuses major pre-GA, so the level is minor, with the banner carrying the breaking-ness.
  • That is exactly the form of .changeset/20061-rest-limit-parsing.md (PR fix(rest): refuse a ?limit= the door cannot read on import jobs, export, meta history and search (#20061, #20062) #20137), whose marker category is identical. Its Check Changeset run is green here.
  • The migration one-liner ("send the parameter as a number ... or omit it") is present.
  • It is not a widening: no door refused any single-valued numeric value before, and every conforming value is pinned as reaching the service unchanged.
  • The per-door statements were verified against the producers:
    • audit: default 100, clamp [1, 500] (protocol.zod.ts:1427-1429);
    • approvals: unpaged 500, paged 50, clamp [1, 200] (approval-service.ts:104-109);
    • search: default 5, clamp [1, 25] (protocol.ts:11866);
    • history: cursor semantics (sys-metadata-repository.ts:1207).
  • The claim line's bare no (comment 5860861960) and the body's and changeset's no (narrowing) are consistent. The claim's own prose names no (narrowing) for the changeset, and a claim line carries only yes or no.

③ Boundary flags

  • Files outside the amended surface: none. The four files are packages/rest/src/rest-server.ts, packages/rest/src/rest-server-query-number-census.test.ts, packages/rest/src/rest-server-query-number-reads.test.ts and .changeset/20139-rest-query-number-census.md. All are named by claim 5860861960 as amended, and packages/spec/** is untouched.
  • Fences: there is no overlap with any of the three.
  • PR body vs diff: these all match the diff: the hunk list; "no export line changed"; "reader body unchanged"; "three dead Number.isFinite drops removed"; "10 sites, no parseInt/parseFloat/unary +"; the rider; and the added AuditMetaItemRequestSchema import. The export "byte-identical" sentence is true of the fixture used, and the header-fallback caveat is declared in the same body.
  • The docs drift comment (5861283512): it flags three hand-written pages through path literals in comments on changed lines. None of them states sinceSeq, perObject, /diff from/to or approvals paging, and upgrading.mdx's fromVersion/toVersion is the release manifest. No doc edit is owed.
  • CI on d07bbaa79 (read 2026-09-28, last): 33 check-runs.
    • 28 success: Test Core 6/6, Build Core, Check Changeset, Dogfood Regression Gate 3/3, Dogfood Verify CLI, Temporal Conformance, Governed Surface Queue Guard, Type Check source/consumer/debt ledger, and the claim and single-writer guards.
    • 3 skipped: Build Docs, Console Pin Gate, and Packed-tarball smoke (opt-in).
    • 2 still in_progress at read time: Lint & Repo Gates and Type Check · workspace.
    • The combined status is success. The PR is a draft, mergeable_state: blocked, with no reviews.
    • Landing waits on the two in-progress runs.

Implemented-by: claude/issue-20139-rest-query-number-census
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the triage direction and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 00:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 329ea2e Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20139-rest-query-number-census branch September 28, 2026 01:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants