Skip to content

Commit 9bb059d

Browse files
os-elon-muskclaude
andauthored
fix(spec): the object-grid arm takes the page-size accept set the view arm rules (#19046) (#19095)
Fixes #19046 Clause-②: yes The `object-grid` page-component door declared `pagination: z.unknown()` and `pageSize: z.number()`, so the same authored member carried **two accept sets** and renderers read the looser one. This bounds the page-size members to the accept set the view arm has ruled all along, and deliberately leaves the `pagination` bag open. ## The premise, re-derived by symbol at this branch's base (`362035cc0`) ⛔ No line number inherited from the card — triage warned about exactly that, and the card's own reading was taken on `abb01f1`. | arm | symbol | declaration at my base | accepts `0`? | |:--|:--|:--|:--| | view | `PaginationConfigSchema` (`packages/spec/src/ui/view.zod.ts:867-868`) | `pageSize: z.number().int().positive().default(25)` · `pageSizeOptions: z.array(z.number().int().positive()).optional()` | no | | grid component | `ObjectGridPropsSchema` (`packages/spec/src/ui/component.zod.ts:2632`, `:2634`) | `pagination: z.unknown().optional()` · `pageSize: z.number().optional()` | **yes — both** | The view arm's refusals are pinned **by name** (`view.test.ts` — `should reject negative pageSize`, `should reject zero pageSize`, and the same pair for `pageSizeOptions`). The corpus corroboration also holds at my base — every other page-size declaration in the package is bounded: ``` packages/spec/src/ui/view.zod.ts:867 z.number().int().positive().default(25) packages/spec/src/ui/view.zod.ts:868 z.array(z.number().int().positive()) packages/spec/src/ui/component.zod.ts:2634 z.number() ** the outlier packages/spec/src/marketplace/marketplace.zod.ts:435 z.number().int().min(1).max(100).default(20) packages/spec/src/marketplace/marketplace.zod.ts:456 z.number().int().min(1) packages/spec/src/kernel/metadata-plugin.zod.ts:399 z.number().int().min(1).max(500).default(50) packages/spec/src/kernel/metadata-plugin.zod.ts:429 z.number().int().min(1) ``` PR #18638, which held this file, is merged (2026-09-18T16:01:37Z) and did **not** tighten it in passing, so triage's downgrade clause does not apply. ## The shape decision — a permissive object, and the evidence that chose it The card's complaint is that the two arms disagree about a page **size**. It is ⛔ not that `pagination` should become a closed shape. Two shapes were plausible; the evidence is one-sided. **Chosen: `z.looseObject({ pageSize, pageSizeOptions })`** — validates the two declared members, passes every other key through. **Rejected: `z.unknown()` plus a refinement judging only `pageSize`.** It looks more conservative and is measurably worse here: - `z.toJSONSchema()` has **no arm for a `custom` check**. A record, the same record with a `.refine()`, and the same record with an aborting `.refine()` all project byte-identically — the mechanism `packages/spec/dropped-refinements.baseline.json` exists to record. A refinement would have left the **published** JSON Schema still accepting `pageSize: 0` while the parser refused it, and it would have needed a **new row in that shrink-only ledger**, which is a ratchet this dev may not raise. - The loose object is a **type** narrowing, so it projects. Measured on the built artifact: ``` packages/spec/json-schema/ui/ObjectGridProps.json pagination.properties.pageSize { "type": "integer", "exclusiveMinimum": 0 } pagination.properties.pageSizeOptions.items { "type": "integer", "exclusiveMinimum": 0 } pagination.additionalProperties {} ** the bag stays OPEN pageSize { "type": "integer", "exclusiveMinimum": 0 } ``` `dropped-refinements.baseline.json` is **untouched** by this PR: `ui/ObjectGridProps` keeps its single pre-existing `filter.element` site and gains none. **Read points, measured at objectui `d18322415`** (the sibling checkout in this container; the `.objectui-sha` pin is `53ded82bf`): `ObjectGrid.tsx:1209` and `:1628` read `(schema.pagination as any)?.pageSize ?? schema.pageSize`, `:4179` reads `schema.pagination?.pageSize`, `:4359` reads `schema.pagination?.pageSizeOptions`. Across objectui's whole source, `pageSize` and `pageSizeOptions` are the **only two members** any `pagination` read point names (37 + 6 reads of `.pageSize`, 7 + 3 of `.pageSizeOptions`, zero of anything else). The objectui registry declares this input `type: 'object'` (`plugin-grid/src/index.tsx:223`). ### What was NOT narrowed, and why - **Sibling keys inside the bag.** `z.looseObject`, not `strictObject`: a sibling key that parsed before still parses **and still survives the parse byte-identically**. Reusing `PaginationConfigSchema` here would have refused every one of them — the `…` in this door's own describe says authors write them — which is a wider breaking change than the card's premise and a different decision. §3 of the new pin is what makes that auditable; §4 records the deliberate asymmetry (the view arm stays closed, this bag stays open), so a future author harmonising the two arms reds a case instead of discovering the consequence in a renderer. - **No `.default(25)` added to the flat shorthand.** The view arm has one; adding one here would change parsed output, not the accept set. - **`pageSizeOptions` WAS bounded, and that is a judgement I am naming rather than burying.** It is the same defect class by a second door: `pageSizeOptions: [0, 25]` puts a zero entry in the page-size selector, which sets the fetch window to zero rows — the card's exact failure. Its shape was already pinned by the view arm (`z.array(z.number().int().positive())`), whose zero/negative refusals are pinned by name, and its read point is measured above. Corpus cost: zero `pageSizeOptions` entries outside the spec's own refusal fixtures are non-positive. ### One second axis, stated rather than left to be discovered `pagination` moves from `z.unknown()` to an object type, so a non-object value (`pagination: true`) is refused where it used to parse. Measured before narrowing: - **zero** non-object `pagination` values on an `object-grid` node in either repository (the `pagination: false` hits in objectui are on `data-table` / `object-data-table`, whose props this schema does not declare, plus one internal per-group table the grid builds itself at `ObjectGrid.tsx:4590`); - the registry has published `type: 'object'` all along, so the html tier already answered `type-mismatch` on one while this schema accepted it — the same shape the `sort` docblock two members up already records; - `ObjectGrid.tsx:4175` reads the key for **presence** (`schema.pagination !== undefined ? true : …`), which means an authored `pagination: false` used to turn paging **ON**. That value now gets a located refusal instead of the opposite of what it says. ## Pins, each with its control New file: `packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts` — 19 cases, 4 sections. | section | asserts | control | |:--|:--|:--| | §1 | `pagination.pageSize` refuses zero / negative / non-integer, and `pageSizeOptions` entries refuse zero / negative — each asserting the issue **code and path** (`too_small` at `pagination.pageSize`), not a bare throw | two LIT CONTROLS: a legal `pageSize` parses and is preserved; the whole ruled bag parses with its options | | §2 | the flat shorthand carries the same accept set, by name | a LIT CONTROL: `pageSize: 25` parses and keeps its value | | §3 | a sibling key in the bag parses with **no `unrecognized_keys` issue**, survives byte-identically (`toStrictEqual`), and a bag of only sibling keys parses | this section IS the control for the trap above | | §4 | both arms refuse the same three non-page-sizes, and both accept `50` | an unknown KEY is refused by the view arm (`unrecognized_keys`) and accepted by the component bag — the asymmetry, pinned | **Defect reproduced in this tree, then the refusal proved able to fail.** Ablation through `scripts/ablation-replace.mjs`, anchor `const GridPageSizeSchema = z.number().int().positive();` replaced by `const GridPageSizeSchema = z.number();` (the pre-PR accept set), from the committed state: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob d9e4dec -> 462c333a1bda Test Files 1 failed (1) Tests 11 failed | 8 passed (19) FAIL §1 ... > should reject zero pageSize AssertionError: expected true to be false ** parse({ pagination: { pageSize: 0 } }) SUCCEEDS ablation-replace: ok restored: blob == HEAD (d9e4dec) and `git diff HEAD` is empty ``` The 11 that reddened are exactly §1/§2/§4's refusals; the 8 that stayed green are the lit controls and §3's openness pins — the right partition, since the ablation removed only the value bound. Restored again through the explicit form: `git checkout HEAD -- packages/spec/src/ui/component.zod.ts`, then `git hash-object` equal to `git rev-parse HEAD:` that path (`d9e4decd6443…`), `git diff HEAD` empty and `git status --porcelain` empty — and the pin re-run green (19/19) from the restored tree. ## Changeset — the derivation, quoting the rule `.changeset/19046-object-grid-page-size-accept-set.md` grades `@objectstack/spec: minor`, carries the BREAKING banner, `Clause-②: yes (narrowing)`, a FROM → TO table and the ADR-0087 disposition. - `scripts/check-changeset-no-major.mjs` header: **"During the launch window we ship breaking changes as `minor`"**, and its end condition — **"at GA … an accept-set narrowing … grades `major`. Until then it is NOT the carrier"** — with `major` refused outright by the guard. So the rule does ⛔ not point at `major`, and there is nothing here for the maintainer floor to rule on. - `pr-automation.yml` "WHICH LEVEL": a widening takes at least `minor`, and the level axis refuses `patch` across the board on a PR that declares clause ②. Declaring `Clause-②: yes` therefore forces at least `minor` — which is where the launch-window rule already put it. - Direction carriers, per the same header: the **BREAKING banner** plus the **ADR-0087 disposition**. Disposition is `registered ui-object-grid-page-size-positive-integer-refused`, a new semantic entry — the four `not-required` categories are all refused by construction here (`unpublished`: spec publishes; `no-migration-prescription` and `runtime-interface-only`: the body carries a FROM → TO table, and "a changeset that ships instructions for rewriting a consumer's code cannot also claim that no consumer has to rewrite anything"; `type-surface-only`: this is a runtime accept set on a metadata surface, not a type annotation). - `skip-changeset` was never available: this moves a published accept set on a package that ships. Verdicts: `check-changeset-no-major.mjs` exit **0**; `check-adr-0087-registration.mjs` exit **0** — `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. ## Verification Full census derived from the real change set after the changeset existed, at `8ecc9b6ed`, with every exit code captured **before** any pipe: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 8 path(s) vs merge base 07c6f82, three-dot; 109 commands 107 exit 0 · 2 PREREQUISITE NOT MET (exit 3) · 0 findings ``` The two that could not run, neither a pass nor a finding: | family | reason | what it needs | |:--|:--|:--| | `pnpm check:dual-build-cjs-loads` | `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/` (34 packages) | a repo-wide `pnpm build`; CI's `Build Core` supplies it | | `pnpm check:type-check-debt` | `--re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk — @objectstack/driver-turso` | `turbo run build --filter='./packages/*' --filter='./packages/*/*'`, as lint.yml does | Four families reported `PREREQUISITE NOT MET` or a missing input on first run and were then **made to run** rather than declared: `check:doc-formula-expressions` and `check:doc-security-posture` (needed `@objectstack/formula` + `@objectstack/lint` built) and `check:skill-examples` (needed `@objectstack/client-react`'s closure) all became exit 0; `check:react-declaration-parity` was run as CI runs it (`MANIFEST="$PWD/sdui.manifest.json" … --strict`) and reports **no new declaration divergence vs the accepted baseline**. Beyond the census: - `pnpm --filter @objectstack/spec test` — **495 files / 14539 tests pass** (post-merge); `typecheck` green, test-layer ledger unmoved at 54 files / 259 errors / 144 pinned signatures. - `pnpm --filter @objectstack/spec check:generated` — **all 16 generated artifacts up to date**. Three were proved stale and regenerated with `--fix` only (`api-surface-declarations/`, `content/docs/references/**`, the strictness-ledger counts); the `authorable-surface.base.json` anchor was never touched. - The one in-repo consumer of the changed surface is `packages/lint` (`ComponentPropsMap`, `@objectstack/spec/ui`): `typecheck` green with its ledger unmoved (2 files / 6 errors / 2 pinned), `test` **104 files / 3910 tests pass**. No corpus fixture anywhere in `examples/`, `apps/` or another package authors `pagination` on an `object-grid` node, so nothing in the tree newly fails to parse. - Repo-wide `pnpm lint` (`eslint . --no-inline-config`) — exit **0**, whole tree, no narrowing claimed. - `pnpm check:nul-bytes` exit 0, plus a direct control-character scan over all 8 changed paths — clean. - Merged `origin/main` through `scripts/pm/os-regen-merge.sh` (its step 2 took main's side of `api-surface-declarations/ui.txt`, which both sides moved, and step 3's hook held the regeneration debt until it was discharged). This branch's delta against `origin/main` on that shard is now **exactly the two `pagination` hunks**, with main's own advance intact. ### The widening-tells reading, with its caveat ``` node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF -> exit 0 ✓ the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already routes to contract review, so a tell on top of it decides nothing. ``` ⚠️ **That exit 0 is the absence of a reading, not a clean one.** With `yes` the gate short-circuits and examines **no file**. Run as a **diagnostic only** with `--declaration no`, it exits 4 on two T1 tells: `component.zod.ts:2689` (`pageSizeOptions`) and `:2692` (`pageSize`) — *"a new key on a Zod object schema"*. Textually right, semantically inverted for this diff: both members were already writable through `z.unknown()`, which accepted everything; what the diff does is **bound** them. That is a limitation of the matcher, not a signal about this PR, and it is in the acceptance notes below rather than repaired here. ## Acceptance notes *The two paragraphs below were added by the `domain:spec#3` seat after the body's single dev write, on the dev's own hand-over; ⛔ a dev writes a PR body once, at creation.* **The migration registry, with four open PRs adding entries to it.** Mine, #19090, #19084 and #18319 each add one semantic entry. Identity cannot collide silently: the entry id IS the identity and the **filename is a function of it**, so a duplicate would be a loud git add/add conflict — the generator says so in as many words, and the four ids are four distinct files. Order is **derived** `(major, id)` from the directory listing, with no index file and no positional consumer (`migrations/chain.ts` keys by MAJOR, `MIGRATIONS_BY_MAJOR[m]`), so a clean text merge cannot express a wrong *meaning* — the `18.` prefix is the protocol-major bucket, not a sequence number. The gate is `pnpm --filter @objectstack/spec check:migration-registry`, run at exit 0 (「229 semantic, 195 retired-key, 181 retired-def」 current): it proves the emitted regions equal what the entries directory says, so a merge that dropped one side reds and one that kept both out of order reds too. Adjacency measured over the 141 existing `18.*` entries plus the four in flight: **7 / 49 / 61** existing entries lie between mine and #19090 / #19084 / #18319 — no pair is adjacent, and the register's own insertion-only property then predicts a clean, current union whatever the landing order. ⚠️ And `registry.ts` is deliberately **NOT** in the `merge=os-regen` register (classified MIXED, 「a deferral would launder the prose」), so a conflict there is **loud and a human's** — the silent-drop class does not reach it. **The hand-written docs negative, recorded so it is not reopened.** Probe: hand-written `content/docs` trees (excluding `references/` and `releases/`) authoring a `pageSize` value this narrowing refuses (`0`, negative, decimal) → **ZERO**. **Lit control, same instrument:** it does find authored `pageSize` occurrences — `content/docs/api/data-api.mdx:42` (`?pageSize=5`) and `content/docs/api/error-catalog.mdx:151` — over 2 hand-written pages and 9 pages including the generated tree, so the zero is a reading rather than a dead grep. **Attribution, which is the part that matters:** neither control hit is this door's `pagination.pageSize` — `data-api.mdx` documents `pageSize` as an *unknown REST query parameter* refused in favour of `top` / `$top` / `limit`, and the remaining pages are the metadata response shape, the object page and the metadata-plugin page. Four different `pageSize` members, none of them this one. ⇒ nothing owed on the hand-written side; the generated `content/docs/references/ui/component.mdx` already moved in this diff. The attribution step is the prescription of **#19093**, filed today after a name-based hit produced a false stop-the-line alarm on a sibling PR. Observations found in passing. ⛔ None is filed as a card by this PR, and none is in its scope. - **The widening-tells matcher cannot tell a narrowing-inside-a-bag from a widening.** A PR that honestly declares `Clause-②: no (narrowing)` — a legal, precedented declaration (`.changeset/17499-groupbyfield-non-padded.md` carries exactly it) — and bounds a member inside a previously-`z.unknown()` bag is blocked at exit 4 by a T1 tell that names the bound as a widening, because the matcher reads the added key text and not the member's prior schema. Reproduced on this diff, above. The honest declaration is the blocked one. The successor: the next accept-set narrowing on this board. Dedupe words: `widening-tells T1 narrowing inside z.unknown bag`, `check-widening-tells false tell narrowing`, `clause-2 no narrowing blocked exit 4`. - **`frozenColumns: z.number().optional()`** on this same door (`component.zod.ts`) is unbounded, and the renderer reads it as a leading-column count. ⛔ Not filed and ⛔ not touched: no repro, no measured consumer breakage, and it is not this card's member. Noted, not filed. The successor is any future PR on this door's numeric members. - **`pagination: false` / `pagination: true` on `data-table` / `object-data-table`** is authored in objectui and those props are not declared in `ComponentPropsMap` at all, so nothing in this repo judges them. Noted, not filed; that is the sibling repo's declaration surface, not this door's. ## Notes for the reviewer - ⛔ This PR does **not** hang, clear or touch `needs:contract-review`, and writes **no label** — both carriers are the seat's write. `Clause-②: yes` is here because triage ruled it; ⛔ this author does not review its own clause-② verdict. - `packages/spec/api-surface-declarations/ui.txt` moved because the declaration text moved. PR #19024 removes all 17 of those shards; a deletion-versus-modification conflict there resolves in favour of the deletion and is expected — ⛔ not pre-solved here. - No governed surface is in the diff (checked against `GOVERNED_SURFACES` in `scripts/pm/check-governed-merges.mjs`): `docs/audits/` is not `docs/adr/`. - objectui#9853 is the consumer half's card and objectui#9896 its landed repair; this is the declaration half and was never a prerequisite for it. objectstack#18972 names this same class on the declaration side, and #19083 landed its `scale` instance three commits before this branch's merge base. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a675ad4 commit 9bb059d

8 files changed

Lines changed: 384 additions & 14 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for authored metadata** — the `object-grid` page-component door now refuses a page size of `0`, a negative page size and a non-integer page size, at all three of its spellings: `pagination.pageSize`, every `pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand (#19046).
6+
7+
Clause-②: yes (narrowing)
8+
9+
The accept set shrinks to the one the VIEW arm has ruled all along. `PaginationConfigSchema` (`view.zod.ts`) declares `pageSize: z.number().int().positive()` and pins its refusals by name; `MetadataQuery` and the two marketplace request schemas say `z.number().int().min(1)`, each with its own throwing pin. The `object-grid` door said `pagination: z.unknown()` and `pageSize: z.number()` — the only page-size declaration in the package that accepted `0`, and the one renderers read.
10+
11+
**It was not theoretical.** Measured at objectui#9853: an authored `pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` and rendered ZERO ROWS, with no grouping needed to trigger it — through this arm, with a `success: true` receipt from this schema. The view arm would have refused the same value. objectui#9896 repaired the consumer half (a resolver at every read point, fail-soft, one loud diagnostic); this is the declaration half and is not a prerequisite for it.
12+
13+
```
14+
✗ pagination.pageSize: Too small: expected number to be greater than 0
15+
✗ pageSize: Invalid input: expected int, received number
16+
```
17+
18+
### Migration — FROM → TO
19+
20+
| You wrote | Write instead |
21+
| --- | --- |
22+
| `pagination: { pageSize: 0 }` | `showPagination: false` and no `pagination` bag — the bag's PRESENCE is what enables paging, so `pageSize: 0` never meant "no paging" |
23+
| `pagination: { pageSize: 0 }` (meaning "all rows on one page") | the page size you actually want (`{ pageSize: 100 }`); `0` reached the wire as `$top: 0` and returned nothing |
24+
| `pagination: { pageSizeOptions: [0, 25, 50] }` | `{ pageSizeOptions: [25, 50] }` — drop the `0` entry; selecting it set the fetch window to zero rows |
25+
| `pageSize: 25.5` | `pageSize: 25` — a fractional page size was truncated or forwarded verbatim, depending on the read point |
26+
27+
The one-line fix is always the same: **write a positive integer, or delete the key and take the renderer's default.**
28+
29+
<!-- adr-0087: registered ui-object-grid-page-size-positive-integer-refused -->
30+
31+
**⛔ What this deliberately does NOT narrow: the `pagination` bag stays OPEN.** The card's defect is that the two arms disagreed about a page SIZE — not that the bag should become a closed shape. `pagination` is now a `z.looseObject` that validates the two members whose value is a page size and passes every other key through unvalidated, so a sibling key that parsed before still parses and still survives the parse byte-identically (pinned in `component-object-grid-pagination-accept-set.pin.test.ts` §3). Reusing the view arm's `PaginationConfigSchema` here would have refused every sibling key this door has accepted since it was written — the `` in its own describe says authors write them — which is a wider narrowing than the measured defect and a different decision. `PaginationConfigSchema` itself is unchanged and stays closed; §4 of that pin states both the agreement and the deliberate asymmetry.
32+
33+
**One second axis, named rather than left to be discovered.** `pagination` moves from `z.unknown()` to an object type, so a non-object value (`pagination: true`) is refused where it used to parse. Measured before narrowing: zero non-object `pagination` values exist on an `object-grid` node in either repository's corpus, the objectui registry has published this input as `type: 'object'` all along (`plugin-grid/src/index.tsx`), so the html tier already answered `type-mismatch` on one, and the renderer reads the key for PRESENCE (`schema.pagination !== undefined`) — which means an authored `pagination: false` used to turn paging ON. That value now gets a located refusal instead of the opposite of what it says.

content/docs/references/ui/component.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,8 @@ Sort field and direction pair
514514
| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` |
515515
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` |
516516
| **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
517-
| **pagination** | `any` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging |
518-
| **pageSize** | `number` | optional | Flat page-size shorthand; `pagination.pageSize` wins when both are set |
517+
| **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record<string, any>` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated |
518+
| **pageSize** | `integer` | optional | Flat page-size shorthand, a positive integer; `pagination.pageSize` wins when both are set |
519519
| **showPagination** | `boolean` | optional | Show the pager (read only when `pagination` is absent) |
520520
| **searchableFields** | `string[]` | optional | Fields the toolbar search queries; a non-empty list enables search |
521521
| **showSearch** | `boolean` | optional | Show the search box (read only when `searchableFields` is absent) |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 449 |
24+
| Object sites in them | 450 |
2525
| Still-open (strip) sites | 126 |
2626
| Files carrying at least one | 22 |
2727

@@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4444

4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
47-
| `ui/` | 175 | 165 | 3 | 0 | 7 |
47+
| `ui/` | 176 | 165 | 4 | 0 | 7 |
4848
| `data/` | 159 | 76 | 1 | 0 | 82 |
4949
| `automation/` | 68 | 43 | 0 | 1 | 24 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **449** | **318** | **4** | **1** | **126** |
52+
| **total** | **450** | **318** | **5** | **1** | **126** |
5353

5454
## File-level triage — site counts
5555

@@ -66,7 +66,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6666
| `app.zod.ts` | 18 |
6767
| `bulk-action.zod.ts` | 3 |
6868
| `chart.zod.ts` | 8 |
69-
| `component.zod.ts` | 46 |
69+
| `component.zod.ts` | 47 |
7070
| `dashboard.zod.ts` | 11 |
7171
| `dataset.zod.ts` | 4 |
7272
| `i18n.zod.ts` | 1 |
@@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7676
| `sharing.zod.ts` | 1 |
7777
| `view.zod.ts` | 61 |
7878
| `widget.zod.ts` | 1 |
79-
| **total** | **175** |
79+
| **total** | **176** |
8080

8181
### `data/` — sites
8282

@@ -155,15 +155,15 @@ over it is here.
155155

156156
### `ui/` — open
157157

158-
**7 strip of 175**, in 4 file(s).
158+
**7 strip of 176**, in 4 file(s).
159159

160160
| File | Strip | Sites |
161161
|---|---|---|
162162
| `action-params.zod.ts` | 1 | 1 |
163163
| `app.zod.ts` | 1 | 18 |
164164
| `view.zod.ts` | 4 | 61 |
165165
| `widget.zod.ts` | 1 | 1 |
166-
| **total** | **7** | **175** |
166+
| **total** | **7** | **176** |
167167

168168
| Bucket | Sites |
169169
|---|---|

packages/spec/api-surface-declarations/ui.txt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4470,7 +4470,10 @@ declare const ComponentPropsMap: {
44704470
}>;
44714471
}, z.core.$strip>>>;
44724472
defaultSort: z.ZodOptional<z.ZodNever>;
4473-
pagination: z.ZodOptional<z.ZodUnknown>;
4473+
pagination: z.ZodOptional<z.ZodObject<{
4474+
pageSize: z.ZodOptional<z.ZodNumber>;
4475+
pageSizeOptions: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
4476+
}, z.core.$loose>>;
44744477
pageSize: z.ZodOptional<z.ZodNumber>;
44754478
showPagination: z.ZodOptional<z.ZodBoolean>;
44764479
searchableFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -11094,7 +11097,10 @@ declare const ObjectGridPropsSchema: z.ZodObject<{
1109411097
}>;
1109511098
}, z.core.$strip>>>;
1109611099
defaultSort: z.ZodOptional<z.ZodNever>;
11097-
pagination: z.ZodOptional<z.ZodUnknown>;
11100+
pagination: z.ZodOptional<z.ZodObject<{
11101+
pageSize: z.ZodOptional<z.ZodNumber>;
11102+
pageSizeOptions: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
11103+
}, z.core.$loose>>;
1109811104
pageSize: z.ZodOptional<z.ZodNumber>;
1109911105
showPagination: z.ZodOptional<z.ZodBoolean>;
1110011106
searchableFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-object-grid-page-size-positive-integer-refused',
7+
surface: '`object-grid` page-component page sizes '
8+
+ "(`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each "
9+
+ '`pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — '
10+
+ 'zero, negative and non-integer values (`pagination: { pageSize: 0 }`, '
11+
+ '`pageSize: 25.5`)',
12+
replacement: 'a positive integer, or no declaration at all. A page size of `0` has no '
13+
+ 'defined meaning on this surface and never had one: delete the key to take the '
14+
+ "renderer's own default, or write the page size that was meant (`pageSize: 0` "
15+
+ 'authored to mean "no paging" is `showPagination: false` with no `pagination` bag, '
16+
+ "since the bag's PRESENCE is what enables paging)",
17+
reason:
18+
'#19046: this door carried the pre-#7751 read-point shape — `pagination: z.unknown()` '
19+
+ 'and `pageSize: z.number()` — after the view arm converged on '
20+
+ '`z.number().int().positive()`. So the SAME authored member carried two accept sets '
21+
+ 'and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses '
22+
+ '`pageSize: 0` and pins that refusal by name, and every other `pageSize` the package '
23+
+ 'declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, '
24+
+ '`marketplace/marketplace.zod.ts`) — the component arm was the only one that '
25+
+ 'accepted `0`. The value is LIVE: measured at objectui#9853, an authored '
26+
+ '`pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` '
27+
+ 'and rendered ZERO ROWS, with no grouping needed to trigger it, and it reached the '
28+
+ 'renderer through this arm. objectui#9896 repaired the consumer half (a resolver at '
29+
+ 'every read point, fail-soft, one loud diagnostic); this is the declaration half, '
30+
+ 'and it is not a prerequisite for that repair. '
31+
+ '⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members '
32+
+ 'whose value is a page size are bounded, and sibling keys parse and pass through '
33+
+ 'exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and '
34+
+ 'is unchanged by this entry.',
35+
acceptanceCriteria:
36+
'Every `object-grid` node declaring a page size — inside `pagination` or through the '
37+
+ 'flat shorthand — carries a positive integer. Well-formed values (`10`, `25`, `50`) '
38+
+ 'parse byte-identically to before, a `pagination` bag carrying sibling keys parses '
39+
+ 'and keeps them, and absence stays absence. A stored page whose `object-grid` node '
40+
+ 'carries `pageSize: 0` is refused on its next authoring-path save with a per-key '
41+
+ 'issue at `pagination.pageSize`; the author deletes the key or writes the page size '
42+
+ 'they meant.',
43+
};

packages/spec/src/migrations/registry.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12274,6 +12274,45 @@ const step18: MigrationStep = {
1227412274
+ 'anything), and behaviour that seems to need one is a renderer capability request '
1227512275
+ 'against objectui, not a metadata key.',
1227612276
},
12277+
{
12278+
id: 'ui-object-grid-page-size-positive-integer-refused',
12279+
surface: '`object-grid` page-component page sizes '
12280+
+ "(`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each "
12281+
+ '`pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — '
12282+
+ 'zero, negative and non-integer values (`pagination: { pageSize: 0 }`, '
12283+
+ '`pageSize: 25.5`)',
12284+
replacement: 'a positive integer, or no declaration at all. A page size of `0` has no '
12285+
+ 'defined meaning on this surface and never had one: delete the key to take the '
12286+
+ "renderer's own default, or write the page size that was meant (`pageSize: 0` "
12287+
+ 'authored to mean "no paging" is `showPagination: false` with no `pagination` bag, '
12288+
+ "since the bag's PRESENCE is what enables paging)",
12289+
reason:
12290+
'#19046: this door carried the pre-#7751 read-point shape — `pagination: z.unknown()` '
12291+
+ 'and `pageSize: z.number()` — after the view arm converged on '
12292+
+ '`z.number().int().positive()`. So the SAME authored member carried two accept sets '
12293+
+ 'and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses '
12294+
+ '`pageSize: 0` and pins that refusal by name, and every other `pageSize` the package '
12295+
+ 'declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, '
12296+
+ '`marketplace/marketplace.zod.ts`) — the component arm was the only one that '
12297+
+ 'accepted `0`. The value is LIVE: measured at objectui#9853, an authored '
12298+
+ '`pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` '
12299+
+ 'and rendered ZERO ROWS, with no grouping needed to trigger it, and it reached the '
12300+
+ 'renderer through this arm. objectui#9896 repaired the consumer half (a resolver at '
12301+
+ 'every read point, fail-soft, one loud diagnostic); this is the declaration half, '
12302+
+ 'and it is not a prerequisite for that repair. '
12303+
+ '⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members '
12304+
+ 'whose value is a page size are bounded, and sibling keys parse and pass through '
12305+
+ 'exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and '
12306+
+ 'is unchanged by this entry.',
12307+
acceptanceCriteria:
12308+
'Every `object-grid` node declaring a page size — inside `pagination` or through the '
12309+
+ 'flat shorthand — carries a positive integer. Well-formed values (`10`, `25`, `50`) '
12310+
+ 'parse byte-identically to before, a `pagination` bag carrying sibling keys parses '
12311+
+ 'and keeps them, and absence stays absence. A stored page whose `object-grid` node '
12312+
+ 'carries `pageSize: 0` is refused on its next authoring-path save with a per-key '
12313+
+ 'issue at `pagination.pageSize`; the author deletes the key or writes the page size '
12314+
+ 'they meant.',
12315+
},
1227712316
{
1227812317
id: 'ui-react-list-view-binding-aliases-retired',
1227912318
surface: '`kind:\'react\'` page source — `<ListView objectName="…">` and `<ListView viewType="…">` '

0 commit comments

Comments
 (0)