Repository navigation
fix(rest): refuse a ?limit= the door cannot read on import jobs, export, meta history and search (#20061, #20062) - #20137
Conversation
…ing it GET /data/import/jobs, GET /data/:object/export, GET /meta/:type/:name/history and GET /search read ?limit= with a bare Number() and answered 200 with a substituted, clamped or dropped window. Each now reads the parameter through its declared schema (ListImportJobsRequestSchema, HistoryMetaItemRequestSchema) or, where the door declares none, as a whole number, and refuses anything else with 400 VALIDATION_FAILED + fields[] via the shared error mapper. Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn Co-authored-by: Claude <noreply@anthropic.com>
…e the reader's bag parameter The closed-set acceptance loop for GET /search sent limit=x, an unreadable value the door now refuses; it takes a valid value per name, as the export twin already does. The reader's parameter is renamed so the canonical query-slot census does not read it as a query slot. Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn Co-authored-by: Claude <noreply@anthropic.com>
Brings in PR #20128 (app nav doc-audience prune), which landed on rest-server.ts after this branch was cut. No conflict. Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 d133ca317f135943059d290a5f60161ddf7cfb62 && git checkout d133ca317f135943059d290a5f60161ddf7cfb62
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 536f2d52fc5d3aa6067782f884c1798614710f02 74898ed5a7f033240e9c8f4263ce9cd503cfde6c && git checkout -B drift-repro 536f2d52fc5d3aa6067782f884c1798614710f02 && git merge --no-ff 74898ed5a7f033240e9c8f4263ce9cd503cfde6c
node scripts/docs-audit/affected-docs.mjs --json 536f2d52fc5d3aa6067782f884c1798614710f02
|
… read, held by a census (objectstack-ai#20139) (objectstack-ai#20345) Fixes objectstack-ai#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 objectstack-ai#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 file has no `parseInt`, `parseFloat` or unary `+` today. - **For objectstack-ai#20320's claimant:** no numeric query read sits in the `GET /meta/:type` list handler (about :5880 to :6300 on `a78f731ad`). That move needs no census row unless it adds a coercion. - The census is file-scoped, as the card says, so a handler moved out of `rest-server.ts` leaves its view. - **Out of scope by construction:** arithmetic that coerces as a side effect (`x * 1`, `Math.trunc(x)`). ### 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 objectstack-ai#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 | 64 passed`: exactly the 4 `perObject` refusal rows + the census, which names `rest-server.ts:10283 — GET ${basePath}/search » Number(req.query.perObject)` | | A′ | `/diff` back to the old `parseV` helper | anchor 1 → 0; marker count 1; blob → `85ca1f0e` | `Tests 10 failed | 59 passed`: the 9 diff refusal rows + the census, which names `GET ${metaPath}/:type/:name/diff » Number(raw)`, the helper frame a textual heuristic misses | | B | a new `const extra = Number(req.query.extra);` in the search handler | anchor 1 → 0; marker count 1; blob → `1d6d2ea5` | `Tests 1 failed | 68 passed`: the census alone; every per-door pin stayed green, since behaviour was unchanged | - **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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Face review (post-hoc, released)Served-tier: Scope: the changeset (and named docs) of PR #20137, released; this review cannot block anything and exists to find false released prose. Sentence verdicts(
Still true on origin/main?Yes. The reader is still at the four doors ( FindingNONE. Reviewed-by: VERDICT: CLEAN Generated by Claude Code |
Fixes #20061
Fixes #20062
Clause-②: no (narrowing)
What
Four published REST doors read
?limit=with a bareNumber(). They substituted, clamped or dropped the result and answered200:GET /data/import/jobsGET /data/:object/exportGET /meta/:type/:name/historyGET /searchEach door now reads
limitagainst its own declaration and refuses a value outside it.GET /data/import/jobsdoes the same foroffset, because its declaration boundsoffsettoo. The refusal is400with the data surface's existingVALIDATION_FAILED+fields[]envelope, and the service is never called. Nothing a conforming caller sends changes its answer.The change is one private reader in
packages/rest/src/rest-server.ts,readDeclaredQueryNumber, with no new package export:ListImportJobsRequestSchema.shape.limit/.offset;HistoryMetaItemRequestSchema.shape.limit;z.number().int().optional(): a whole number, nothing about range.A non-blank numeric string is coerced with
Number()and handed to the schema. Anything else (abc, a blank string, a structured value) goes to the schema as it came, so the declaration refuses it by type.Number()never gets to invent a0or aNaNfor it.Before and after, measured on the real
RestServerroutes (spy protocol)Every row answered
200before this PR.?limit=0/?limit=-3400,fields[0]=limit/min_value?limit=201/?limit=500400,max_value?limit=abc/1.5/Infinity/ blank400,invalid_type?offset=-1/abc/1.5400,min_value/invalid_type?limit=abc/ empty / blankX-Export-Limit: 1(a one-row export)400,invalid_type?limit=1.5/InfinityX-Export-Limit: 1.5/50000400,invalid_type?limit=abc/Infinity400,invalid_type?limit=(empty) / blank400,invalid_type?limit=abclimit: NaNhanded tosearchAll: no overall cap400,invalid_type?limit=1.5/Infinity/ blank400,invalid_typeUnchanged and pinned by the lit controls:
limit→ 50, absent or emptyoffset→ 0. Conforming values reachfindDataunchanged.?limit=25→ 25. The door's own range handling is untouched:?limit=0→ 1 and?limit=60000→ 50000.limitmember.5,0and1.5are forwarded unchanged, because the declaration isz.number()with noint()and no bounds.undefined.20,0and500reachsearchAllunchanged, and its[1, 100]clamp is its own business.The refusal body carries:
code: 'VALIDATION_FAILED';fields: [{ field: 'limit', code, message }];errorsentence naming the parameter;object, on the export door only. The handler throwsvalidationFailure(@objectstack/types), and each door's existing catch (handleRouteError/mapDataError) writes the 400. Sorest-server.tsgains no response write site, andcheck:route-envelope'ssiblingCodecount on it is unchanged (green).PM mechanism assumptions, measured
parseIntegerParamcycle: confirmed.packages/runtime/package.jsondepends on@objectstack/rest. rest's dependencies arecore,metadata-core,observability,platform-objects,service-package,spec,types(plusexceljs,zod). Nothing was moved;packages/runtime/**andpackages/spec/**are untouched.fields[].code. zod 4.6.1 against the declared schemas, mapped throughzodIssuesToFields:NaN, a string,Infinity, and1.5againstint()all giveinvalid_type;mingivesmin_value; abovemaxgivesmax_value.These are all ADR-0114 catalog members, never zod's own codes. Runtime's
parseIntegerParamanswersinvalid_numberfor the sameabc/1.5; see the open question in the report.CreateExportJobRequestSchemais the contract ofPOST /api/v1/data/:object/export, which rest does not mount.ExportRequestSchema(contract.zod.ts) is bound to no route.searchAllis the REST door (git grep, tests excluded). Sopackages/metadata-protocol/src/protocol.tsis not in this diff.?limit=with their default. Export answered a one-row export (Number('') || 0→Math.max(1, 0)), and history answered zero events (Number('')= 0, forwarded). The rule applied is the oneparseEnumParam(runtimequery-param.ts) states for the same spelling:0, empty is refused.Reading it as absent on export and history would have turned those two into a 10000-row export and the whole change log. That grows the window, the widening this claim stops on, so the reader carries an explicit per-door
emptyIsAbsent.No door accepts anything it refused before: none refused any single-valued
limitbefore this PR. No conforming value's answer changes either, so the declaration staysno (narrowing).Tests
The new file is
packages/rest/src/rest-server-limit-param-parsing.test.ts(43 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.6780e34a: 24 failed (every refusal row answered 200), 19 passed (every lit control).One existing fixture was corrected in
rest-server-closed-query-params.test.ts. Its search closed-set loop sentlimit: 'x'and expected 200; it now takes a valid value per name, as its export twin in the same file already does.Runs at head
74898ed5, after mergingorigin/main:pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2→Test Files 196 passed (196),Tests 3327 passed | 1 skipped (3328).pnpm --filter @objectstack/rest run typecheck→ exit 0.tsc --noEmitpasses, andcheck:test-typecheck: OK — ... 0 file(s) / 0 error(s).Ablations went through
scripts/ablation-replace.mjsin WRAP mode, run after the fix was committed. Vitest readssrc/through the relative import, so no dist was involved.Math.min(200, Math.max(1, Number(q.limit) || 50))landed on disk (anchor 1 → 0, blob02ceccd1→cab07300). Result:Tests 8 failed | 35 passed, exactly the 8 import-jobslimitrefusal rows.req.query?.limit ? Number(req.query.limit) : undefinedlanded (blob02ceccd1→9b589c00). Result:Tests 4 failed | 39 passed, exactly the 4 search refusal rows.02ceccd1,git diff HEADempty.Public surface: rest's
dist/index.d.tsanddist/index.d.cts, built from merge-basebc80e162'srest-server.tsand from HEAD's, are byte-identical (cmp). The restore was proven by blob hash. So no importing package's types can move, and only rest's own tests are owed.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the real diff gives 61 commands, identical to the PM's list. All ran at74898ed5with exit codes captured before any pipe.--ranreconciliation:61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN.check:adr-0087-registration --base origin/main(not-required (no-migration-prescription)accepted),check:changeset-no-major,check:empty-changeset,check:route-envelope,check:nul-bytes,check:doc-authoring,check:published-files,check:issue-citationsandcheck:undeclared-dep-imports.check:dual-build-cjs-loads, exit 3PREREQUISITE NOT MET: 43 workspace packages have nodist/. Declared narrowing:require('./dist/index.cjs')andimport('@objectstack/rest')both load and exposeRestServer.check:type-check-debt, exit 3PREREQUISITE NOT MET: the full closure is not built. See the.d.tsidentity above; CI builds the closure before this step.pnpm lint(the lane addition) ran as the full union,eslint . --no-inline-config, at74898ed5: exit 0.First-party callers, measured
@objectstack/clientsendslimitas written:data.listImportJobs,data.export,meta.getHistory,search.f8a9d0fb:ImportWizardsendslimit: 50,ResourceHistoryPagesendslimit: 100, and the search and export emitters sendlimitonly when it is a number greater than 0. None sends a value this PR refuses.Acceptance notes
GET /meta/:type/:name/history?sinceSeq=abcdropssinceSeqand reads the log from the start (rest-server.ts:8139);GET /meta/:type/:name/audit?limit=abcdropslimit, so the producer's default 100 is served (:8318; declaredz.number(), which refusesNaN);GET /search?perObject=abchandsperObject: NaNtosearchAll(:10709).GET /data/approvals/requestslimit/offsetdrop a non-numeric value and serve the unpaged list (:13601). Export?page=falls back to the 500-row chunk (:10297), which changes chunking only, not the rows returned.limitadmits1.5,0and negatives. The door forwards them as before. WhetherHistoryMetaItemRequestSchema.limitshould declareint()/min()is the spec seat's question, and this PR takes no position.[1, 100]clamp. Both cards take no position on bounds.GET /data/import/jobs?status=is not checked against the declaredImportJobStatus. Read only, not measured, out of scope.origin/mainwas merged (no rebase) after PR fix(rest): app nav prunes doc entries the caller may not read (ADR-0046 §6.7) #20128 landed onrest-server.ts, with no conflict. PR feat!: retire the saved-report stack — /api/v1/reports, client.reports, IReportService, the reports capability, sys_saved_report / sys_report_schedule, @objectstack/plugin-reports #20125's hunks do not overlap these doors.Test Coreshards,Dogfood,Build Core,Temporal Conformance, and the workspace type-check lanes.Generated by Claude Code