Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/19046-object-grid-page-size-accept-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/spec': minor
---

**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).

Clause-②: yes (narrowing)

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.

**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.

```
✗ pagination.pageSize: Too small: expected number to be greater than 0
✗ pageSize: Invalid input: expected int, received number
```

### Migration — FROM → TO

| You wrote | Write instead |
| --- | --- |
| `pagination: { pageSize: 0 }` | `showPagination: false` and no `pagination` bag — the bag's PRESENCE is what enables paging, so `pageSize: 0` never meant "no paging" |
| `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 |
| `pagination: { pageSizeOptions: [0, 25, 50] }` | `{ pageSizeOptions: [25, 50] }` — drop the `0` entry; selecting it set the fetch window to zero rows |
| `pageSize: 25.5` | `pageSize: 25` — a fractional page size was truncated or forwarded verbatim, depending on the read point |

The one-line fix is always the same: **write a positive integer, or delete the key and take the renderer's default.**

<!-- adr-0087: registered ui-object-grid-page-size-positive-integer-refused -->

**⛔ 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.

**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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -514,8 +514,8 @@ Sort field and direction pair
| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` |
| **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` |
| **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. |
| **pagination** | `any` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging |
| **pageSize** | `number` | optional | Flat page-size shorthand; `pagination.pageSize` wins when both are set |
| **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 |
| **pageSize** | `integer` | optional | Flat page-size shorthand, a positive integer; `pagination.pageSize` wins when both are set |
| **showPagination** | `boolean` | optional | Show the pager (read only when `pagination` is absent) |
| **searchableFields** | `string[]` | optional | Fields the toolbar search queries; a non-empty list enables search |
| **showSearch** | `boolean` | optional | Show the search box (read only when `searchableFields` is absent) |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 449 |
| Object sites in them | 450 |
| Still-open (strip) sites | 126 |
| Files carrying at least one | 22 |

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

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 175 | 165 | 3 | 0 | 7 |
| `ui/` | 176 | 165 | 4 | 0 | 7 |
| `data/` | 159 | 76 | 1 | 0 | 82 |
| `automation/` | 68 | 43 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **449** | **318** | **4** | **1** | **126** |
| **total** | **450** | **318** | **5** | **1** | **126** |

## File-level triage — site counts

Expand All @@ -66,7 +66,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `app.zod.ts` | 18 |
| `bulk-action.zod.ts` | 3 |
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 46 |
| `component.zod.ts` | 47 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
| `i18n.zod.ts` | 1 |
Expand All @@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 61 |
| `widget.zod.ts` | 1 |
| **total** | **175** |
| **total** | **176** |

### `data/` — sites

Expand Down Expand Up @@ -155,15 +155,15 @@ over it is here.

### `ui/` — open

**7 strip of 175**, in 4 file(s).
**7 strip of 176**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `view.zod.ts` | 4 | 61 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **175** |
| **total** | **7** | **176** |

| Bucket | Sites |
|---|---|
Expand Down
10 changes: 8 additions & 2 deletions packages/spec/api-surface-declarations/ui.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4470,7 +4470,10 @@ declare const ComponentPropsMap: {
}>;
}, z.core.$strip>>>;
defaultSort: z.ZodOptional<z.ZodNever>;
pagination: z.ZodOptional<z.ZodUnknown>;
pagination: z.ZodOptional<z.ZodObject<{
pageSize: z.ZodOptional<z.ZodNumber>;
pageSizeOptions: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
}, z.core.$loose>>;
pageSize: z.ZodOptional<z.ZodNumber>;
showPagination: z.ZodOptional<z.ZodBoolean>;
searchableFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
Expand Down Expand Up @@ -11094,7 +11097,10 @@ declare const ObjectGridPropsSchema: z.ZodObject<{
}>;
}, z.core.$strip>>>;
defaultSort: z.ZodOptional<z.ZodNever>;
pagination: z.ZodOptional<z.ZodUnknown>;
pagination: z.ZodOptional<z.ZodObject<{
pageSize: z.ZodOptional<z.ZodNumber>;
pageSizeOptions: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
}, z.core.$loose>>;
pageSize: z.ZodOptional<z.ZodNumber>;
showPagination: z.ZodOptional<z.ZodBoolean>;
searchableFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'ui-object-grid-page-size-positive-integer-refused',
surface: '`object-grid` page-component page sizes '
+ "(`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each "
+ '`pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — '
+ 'zero, negative and non-integer values (`pagination: { pageSize: 0 }`, '
+ '`pageSize: 25.5`)',
replacement: 'a positive integer, or no declaration at all. A page size of `0` has no '
+ 'defined meaning on this surface and never had one: delete the key to take the '
+ "renderer's own default, or write the page size that was meant (`pageSize: 0` "
+ 'authored to mean "no paging" is `showPagination: false` with no `pagination` bag, '
+ "since the bag's PRESENCE is what enables paging)",
reason:
'#19046: this door carried the pre-#7751 read-point shape — `pagination: z.unknown()` '
+ 'and `pageSize: z.number()` — after the view arm converged on '
+ '`z.number().int().positive()`. So the SAME authored member carried two accept sets '
+ 'and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses '
+ '`pageSize: 0` and pins that refusal by name, and every other `pageSize` the package '
+ 'declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, '
+ '`marketplace/marketplace.zod.ts`) — the component arm was the only one that '
+ 'accepted `0`. The value is LIVE: 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, and it reached the '
+ 'renderer through this arm. objectui#9896 repaired the consumer half (a resolver at '
+ 'every read point, fail-soft, one loud diagnostic); this is the declaration half, '
+ 'and it is not a prerequisite for that repair. '
+ '⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members '
+ 'whose value is a page size are bounded, and sibling keys parse and pass through '
+ 'exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and '
+ 'is unchanged by this entry.',
acceptanceCriteria:
'Every `object-grid` node declaring a page size — inside `pagination` or through the '
+ 'flat shorthand — carries a positive integer. Well-formed values (`10`, `25`, `50`) '
+ 'parse byte-identically to before, a `pagination` bag carrying sibling keys parses '
+ 'and keeps them, and absence stays absence. A stored page whose `object-grid` node '
+ 'carries `pageSize: 0` is refused on its next authoring-path save with a per-key '
+ 'issue at `pagination.pageSize`; the author deletes the key or writes the page size '
+ 'they meant.',
};
39 changes: 39 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12274,6 +12274,45 @@ const step18: MigrationStep = {
+ 'anything), and behaviour that seems to need one is a renderer capability request '
+ 'against objectui, not a metadata key.',
},
{
id: 'ui-object-grid-page-size-positive-integer-refused',
surface: '`object-grid` page-component page sizes '
+ "(`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each "
+ '`pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — '
+ 'zero, negative and non-integer values (`pagination: { pageSize: 0 }`, '
+ '`pageSize: 25.5`)',
replacement: 'a positive integer, or no declaration at all. A page size of `0` has no '
+ 'defined meaning on this surface and never had one: delete the key to take the '
+ "renderer's own default, or write the page size that was meant (`pageSize: 0` "
+ 'authored to mean "no paging" is `showPagination: false` with no `pagination` bag, '
+ "since the bag's PRESENCE is what enables paging)",
reason:
'#19046: this door carried the pre-#7751 read-point shape — `pagination: z.unknown()` '
+ 'and `pageSize: z.number()` — after the view arm converged on '
+ '`z.number().int().positive()`. So the SAME authored member carried two accept sets '
+ 'and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses '
+ '`pageSize: 0` and pins that refusal by name, and every other `pageSize` the package '
+ 'declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, '
+ '`marketplace/marketplace.zod.ts`) — the component arm was the only one that '
+ 'accepted `0`. The value is LIVE: 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, and it reached the '
+ 'renderer through this arm. objectui#9896 repaired the consumer half (a resolver at '
+ 'every read point, fail-soft, one loud diagnostic); this is the declaration half, '
+ 'and it is not a prerequisite for that repair. '
+ '⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members '
+ 'whose value is a page size are bounded, and sibling keys parse and pass through '
+ 'exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and '
+ 'is unchanged by this entry.',
acceptanceCriteria:
'Every `object-grid` node declaring a page size — inside `pagination` or through the '
+ 'flat shorthand — carries a positive integer. Well-formed values (`10`, `25`, `50`) '
+ 'parse byte-identically to before, a `pagination` bag carrying sibling keys parses '
+ 'and keeps them, and absence stays absence. A stored page whose `object-grid` node '
+ 'carries `pageSize: 0` is refused on its next authoring-path save with a per-key '
+ 'issue at `pagination.pageSize`; the author deletes the key or writes the page size '
+ 'they meant.',
},
{
id: 'ui-react-list-view-binding-aliases-retired',
surface: '`kind:\'react\'` page source — `<ListView objectName="…">` and `<ListView viewType="…">` '
Expand Down
Loading
Loading