Repository navigation
Commit 5f30f00
fix(spec)!: the published list-query schemas declare the page window the Data API enforces (#25300)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6zwDuV2XvoDkqC7bXgqnpc)_
Fixes #25269
Clause-②: no (narrowing)
Metadata-protocol: none
## What changes
The Data API list doors refuse a page window that is not a non-negative
integer (PR #25260, one normalizer in `findData`). The published request
schemas for those doors still declared any number, so a client generated
from them was told `-1`, `1.5` or a blank `?top=` was valid. Now they
declare what the door enforces, from one declaration.
- **One bound, exported by the spec**
(`packages/spec/src/data/data-engine.zod.ts`): `QueryPageWindowSchema`
(`z.number().int().nonnegative()`, which in zod 4 is exactly
`Number.isSafeInteger(n) && n >= 0`) and `QueryPageWindowParamSchema`
(that number, or a non-blank string whose `Number(...)` is one, parsed
to the count). Type aliases per ADR-0122.
- **The normalizer reads it**
(`packages/metadata-protocol/src/protocol.ts`, `pageWindowValue`): the
window is judged by `QueryPageWindowParamSchema.safeParse` instead of a
restated predicate. `metadata-protocol` already imports the spec's
transport tables from `@objectstack/spec/data`, so there is no layering
inversion. Same accept set, same refusal text and code.
- **`FindDataRequestSchema`** (the `POST /data/:object/query` body,
through `QueryWithTransportSchema`): the canonical `limit` / `offset` /
`top` members declare the page window; the transport spellings `$top` /
`$skip` / `skip` (`QueryTransportParamsSchema`) declare it on their
number arm, and the fold's string lowering judges the digits by
`QueryPageWindowParamSchema` (it used to accept any finite number).
`QuerySchema` itself is not touched (see Measured).
- **`HttpFindQueryParamsSchema`** (the `GET /data/:object` querystring
declaration): `top` / `skip` declare `QueryPageWindowParamSchema`
instead of `z.coerce.number()`, which admitted `-1`, `1.5` and a blank
value (as `0`).
- **Generated artifacts**: reference docs, `api-surface`,
`export-origins`, `declaration-map`, `json-schema.manifest`, and two new
rows in `dropped-refinements.baseline.json` (the string arm's refine has
no JSON Schema form; its `Number` semantics cannot be written as a
pattern without approximating it, so it is declared there as the ledger
asks).
## Measured: which schemas a door actually reads (base `8dfd1265f`,
re-checked after merging `bda53bb0b`)
The normalizer judges the top-level `limit` / `offset` of a `findData`
call (`pageWindowValue`, two call sites). `findData` callers: `GET
/data/:object` (querystring, no schema), `POST /data/:object/query`
(validates `FindDataRequestSchema` first), the runtime dispatcher's
twins (same schema on `POST`), declared `object_operation` endpoints,
MCP `query`, action `callData('query')`, and server-built windows
(import runner, connector pull, export loop: all integers).
| Schema (card's list and found) | Read by a door the normalizer judges?
| Disposition |
|:--|:--|:--|
| `FindDataRequestSchema` → `QueryWithTransportSchema` /
`QueryTransportParamsSchema` (`limit`, `offset`, `top`, `$top`, `$skip`,
`skip`) | yes, `POST /data/:object/query` and the dispatcher `POST`
validate it | narrowed |
| `HttpFindQueryParamsSchema` (`top`, `skip`) | describes `GET
/data/:object` (no runtime parse; the SDK reads it as the declaration) |
narrowed |
| `EngineQueryOptionsSchema` (`data-engine.zod.ts:111/114/117`) | no:
`engine.find` options; no door parses it, `ObjectQL.find` does not
validate windows, and in-process callers reach it without the normalizer
| out of scope |
| `QuerySchema` (`limit` / `offset` / `top`) | only through the
transport schema above, where the members are overridden; `QuerySchema`
is also the driver contract and the recursive `expand` value, whose
windows the normalizer never judges | not narrowed |
| `get_record.limit` (`builtin-node-config.zod.ts:628`) | no: the
executor calls the `data` service (`ObjectQL`, `IDataEngine.find` /
`findOne`), not `findData` | out of scope |
| `ListRecordResponseSchema.pagination` (`contract.zod.ts:445/446`) |
no: a response, not a request window | out of scope |
| `AuditMetaItemRequest` / `HistoryMetaItemRequest` /
`ListNotificationsRequest` `limit` (`protocol.zod.ts:1823/1921/3281`) |
no: metadata audit/history and notification routes, not the data list
doors | out of scope |
| `DataEngineFindRequestSchema` (RPC) | no reader outside the spec | out
of scope |
So `Metadata-protocol: none`: no authored metadata window is judged by
the normalizer, and the two narrowed schemas are request schemas. No
migration line is owed for an authored flow `query` window: `get_record`
never reaches the normalizer, so its `limit` is not narrowed here.
**Producer census** (fractional or negative windows): `examples/**` on
this tree, no hit; HotCRM at `4166f2146bec`, 41 files name `get_record`,
every literal `limit` is a positive integer (1, 500, 1000, and
`BILLING_HANDOFF_LINE_ITEM_LIMIT = 200`), no fractional or negative
window anywhere.
## One door answer moves: the code, not the verdict
`POST /data/:object/query` validates its body against
`FindDataRequestSchema` before `findData`, so now that the schema
declares the window, a malformed one is refused there: `400
VALIDATION_FAILED` with `fields[0].field` = `query.limit` /
`query.offset`, where it was `400 INVALID_REQUEST` from the normalizer.
It is still a 400 before any read, and it is the same answer the door
already gave `{"limit":"ten"}`. The runtime dispatcher's `POST` twin
moves the same way, by reading: it asks the same schema and answers
`requestSchemaRefusal`, a `VALIDATION_FAILED`. `GET /data/:object` has
no body schema and is byte-unchanged. Nothing newly refused, nothing
newly accepted. `packages/rest/src/data-page-window-door.test.ts`'s
`POST` pin is updated to assert this. See the open question in the
report.
## Tests
- `packages/metadata-protocol/src/protocol.page-window-declared.test.ts`
(new). The oracle is the door: each value goes through the real
`findData` with a recording engine (accepted iff the engine is reached;
the window it receives is the expected parse output). 13 schema rows
(`FindDataRequest` × 6 spellings, `QueryWithTransport` × 3,
`HttpFindQueryParams` × 2, the two exported schemas) × 17 numbers, 18
strings and 3 other values (`-0`, `MAX_SAFE_INTEGER` and one past it,
`NaN`, `±Infinity`, `' 5 '`, `'1e2'`, `'0x10'`, blank, whitespace,
`'-1'`, `'1.5'`, `true`, `{}`…). Every value the door refuses fails each
schema's parse; every value it accepts passes with the same window.
Number-only members (a JSON body's canonical keys) refuse every string,
as before. 494 cases plus a non-vacuity check.
- `packages/spec/src/data/query-transport.test.ts`: number and digit
windows that are not a count are refused on every spelling (digits at
the canonical path naming the spelling, one diagnostic); `0` stays a
window.
- `packages/spec/src/api/protocol.test.ts`: `HttpFindQueryParamsSchema`
refuses `-1`, `1.5`, blank, whitespace, `abc`; keeps `'0'` and `' 5 '`.
- Ablations (`scripts/ablation-replace.mjs`, run mode, restored by
blob): `limit` member back to `QuerySchema`'s →
`query-transport.test.ts` 2 failed / 70 passed; the fold's string
lowering back to "any finite number" → 4 failed / 68;
`HttpFindQueryParams.top` back to `z.coerce.number()` →
`protocol.test.ts` 4 failed / 191. Each restored, blob equal to HEAD,
`git diff HEAD` empty.
- Full suites, on the tree with `origin/main` at `bda53bb0b` merged,
after building the dependency closure of `@objectstack/runtime`,
`@objectstack/rest` and `@objectstack/dogfood`:
- `pnpm --filter @objectstack/spec test`: 709 files passed, 21545 tests
passed, 1 todo (head `82fb363ac`).
- `pnpm --filter @objectstack/metadata-protocol test`: 259 files passed,
3 skipped; 21410 tests passed, 19 skipped.
- `pnpm --filter @objectstack/rest test`: 316 files, 6327 passed, 326
skipped.
- `pnpm --filter @objectstack/runtime test`: 435 files, 6885 passed, 19
skipped (an importer of `FindDataRequestSchema`).
- `typecheck` exit 0 on `@objectstack/spec`,
`@objectstack/metadata-protocol` and `@objectstack/rest`.
- Full dogfood (owed by the `(narrowing)` arm), as CI runs it, under the
verify lock: VERDICT command-exit 0, 289 files passed and 3 skipped
(292), 2491 tests passed and 18 skipped (2509). The packages were built
at `685efa48` and the tree it ran on was `3fead31f3`. The commits after
it add the `QueryPageWindowParsed` type alias (erased at run time), the
dropped-refinements ledger header counts and regenerated JSON artifacts;
none of them reaches a test at run time.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 114 families on this diff (the same
114 before and after merging main). All 114 ran, every final exit code
is 0, recorded as `command :: exit code`, and `--ran` reconciled them:
114 run, 0 NOT-MEASURED, 0 UNRUN ("a DERIVED zero — all 114 recorded an
exit code and none of them is 3").
- Five needed a second run. Four were local prerequisites:
`check-plugin-teardown-shape --self-test` (the shallow clone lacked its
pinned fixture commit, which was fetched), `check:skill-examples` and
`check:dual-build-cjs-loads` (exit 3: packages with no `dist/` in this
container, which were built), and `check:type-check-debt` (a 600 s local
timeout while its closure build waited for the verify lock). One was a
real finding: `check:spec-parsed-alias` (`QueryPageWindowSchema` had no
`Parsed` alias, added in `3fead31f3`).
- `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up
to date on the final tree. `check-changeset-no-major` and
`check-adr-0087-registration` exit 0 (`not-required
(no-migration-prescription)`).
## Acceptance notes
- `QueryTransportParamsSchema` on its own still accepts any string on
`$top` / `$skip` / `skip`: its digits are judged by the fold inside
`QueryWithTransportSchema` (at the canonical path, naming the spelling,
one diagnostic, the shape `query-transport.test.ts` already pins). No
door parses the fragment alone.
- The JSON Schema for the string arm stays `string` (the refine is
ledgered in `dropped-refinements.baseline.json`); the number arm
publishes `integer`, `minimum 0`, `maximum 9007199254740991`.
- `HttpFindQueryParamsSchema` still declares only `top` / `skip`, while
the GET door also accepts `limit`, `offset`, `$top`, `$skip`. Declaring
those would widen the schema, not this card.
- Nested `expand` windows and `engine.find` windows are not judged by
the normalizer and stay as declared; a direct `engine.find` with
`offset: -1` still meets the driver (PR #25260's acceptance note 2).
---
_Generated by [Claude
Code](https://claude.ai/code/session_0153NL7xGym5Dpyb4sQbsArM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7db1697 commit 5f30f00
16 files changed
Lines changed: 415 additions & 52 deletions
File tree
- .changeset
- content/docs/references
- api
- data
- packages
- metadata-protocol/src
- rest/src
- spec
- api-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
- data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
904 | 904 | | |
905 | 905 | | |
906 | 906 | | |
907 | | - | |
908 | | - | |
909 | | - | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
910 | 910 | | |
911 | 911 | | |
912 | 912 | | |
| |||
917 | 917 | | |
918 | 918 | | |
919 | 919 | | |
920 | | - | |
921 | | - | |
| 920 | + | |
| 921 | + | |
922 | 922 | | |
923 | 923 | | |
924 | 924 | | |
| |||
929 | 929 | | |
930 | 930 | | |
931 | 931 | | |
932 | | - | |
| 932 | + | |
933 | 933 | | |
934 | 934 | | |
935 | 935 | | |
| |||
2055 | 2055 | | |
2056 | 2056 | | |
2057 | 2057 | | |
2058 | | - | |
2059 | | - | |
| 2058 | + | |
| 2059 | + | |
2060 | 2060 | | |
2061 | 2061 | | |
2062 | 2062 | | |
| |||
0 commit comments