From b7552696bdb772624b31d4ab7640145009c34f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 17:53:20 +0000 Subject: [PATCH 1/4] fix(spec): bound the object-grid arm's page sizes to the accept set the view arm rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `object-grid` props door declared `pagination: z.unknown()` and `pageSize: z.number()`, so the same authored member carried two accept sets and renderers read the looser one: `PaginationConfigSchema` refuses `pageSize: 0` and pins that refusal by name, while this door receipted it `success: true`. objectui#9853 measured an authored `pagination.pageSize: 0` reaching `ObjectGrid`, going out as `$top: 0` and rendering zero rows, through this arm. `pagination` becomes a `z.looseObject` that bounds `pageSize` and `pageSizeOptions` to positive integers and passes every other key through unvalidated — the bag stays open on purpose, because closing it would refuse sibling keys this door has accepted since it was written, which is a wider narrowing than the measured defect. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- ...ect-grid-pagination-accept-set.pin.test.ts | 190 ++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 65 +++++- 2 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts diff --git a/packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts b/packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts new file mode 100644 index 0000000000..91f40f4941 --- /dev/null +++ b/packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19046] The `object-grid` arm and the view arm must agree on what a PAGE + * SIZE is — and must keep disagreeing about whether the bag is closed. + * + * ## The defect this file closes + * + * Two arms of this package declared the same authoring member with different + * accept sets, and renderers read the looser one: + * + * | arm | declaration before #19046 | accepted `pageSize: 0`? | + * |:--|:--|:--| + * | view — `PaginationConfigSchema` (`view.zod.ts`) | `z.number().int().positive().default(25)` | no | + * | grid component — `ComponentPropsMap['object-grid']` | `pagination: z.unknown()`, `pageSize: z.number()` | YES, both | + * + * The view arm pins its refusals BY NAME ('should reject negative pageSize' / + * 'should reject zero pageSize', `view.test.ts`), and every other `pageSize` + * this package declares is bounded with its own throwing pin + * (`kernel/metadata-plugin.zod.ts`, `marketplace/marketplace.zod.ts`) — so the + * component arm was the outlier, not the norm. It was not theoretical: + * objectui#9853 measured an authored `pagination.pageSize: 0` reaching + * `ObjectGrid`, going out on the wire as `$top: 0` and rendering ZERO ROWS, + * through this arm. objectui#9896 repaired the consumer half; this is the + * declaration half. + * + * ## The two halves of this pin, and why the second one is not optional + * + * 1. **The page-size accept sets are now one set.** §1 and §2 assert the + * refusals by name on the component arm, each beside a LIT CONTROL that a + * legal value still parses — a refusal pin with no lit control passes just + * as well when the door has stopped accepting anything at all. + * 2. **The bag is still OPEN.** §3 asserts a sibling key inside `pagination` + * still parses and survives byte-identically. The honest fix for §1 is a + * bound on two members; closing the bag would refuse 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. Without §3 that widening lands silently, since + * every §1 assertion passes under it too. + * + * §4 states the cross-arm agreement and the cross-arm asymmetry as one table: + * on a page-size VALUE the two arms answer identically; on an unknown KEY they + * deliberately answer differently (`PaginationConfigSchema` is a `strictObject` + * — `view-union-retirement-prescription.test.ts` §3 pins its `unrecognized_keys` + * — and the component bag is a `z.looseObject`). A future author harmonising + * the two arms 'for consistency' reds §3 and §4 rather than discovering the + * consequence in a renderer. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { ComponentPropsMap } from './component.zod'; +import { PaginationConfigSchema } from './view.zod'; + +const grid = () => ComponentPropsMap['object-grid']; + +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ +function issues(result: z.ZodSafeParseResult): { code: string; path: string }[] { + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} + +/** Every value that is not a page size, with the issue code each one must raise. */ +const NOT_A_PAGE_SIZE: ReadonlyArray = [ + ['zero', 0, 'too_small'], + ['negative', -10, 'too_small'], + ['non-integer', 25.5, 'invalid_type'], +]; + +// ─────────────────────────────────────────────────────────────────────────── +// §1 `pagination.pageSize` — the member the measured defect came through. +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 object-grid `pagination.pageSize` refuses what the view arm refuses', () => { + it('should reject zero pageSize', () => { + const r = grid().safeParse({ pagination: { pageSize: 0 } }); + expect(r.success).toBe(false); + expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSize' }); + }); + + it('should reject negative pageSize', () => { + const r = grid().safeParse({ pagination: { pageSize: -10 } }); + expect(r.success).toBe(false); + expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSize' }); + }); + + it('should reject non-integer pageSize', () => { + const r = grid().safeParse({ pagination: { pageSize: 25.5 } }); + expect(r.success).toBe(false); + expect(issues(r)).toContainEqual({ code: 'invalid_type', path: 'pagination.pageSize' }); + }); + + it('LIT CONTROL — a legal pageSize still parses and is preserved', () => { + const r = grid().safeParse({ objectName: 'showcase_task', pagination: { pageSize: 50 } }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data.pagination).toStrictEqual({ pageSize: 50 }); + }); + + it('should reject zero values in pageSizeOptions', () => { + const r = grid().safeParse({ pagination: { pageSize: 25, pageSizeOptions: [10, 0, 50] } }); + expect(r.success).toBe(false); + expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSizeOptions.1' }); + }); + + it('should reject negative values in pageSizeOptions', () => { + const r = grid().safeParse({ pagination: { pageSize: 25, pageSizeOptions: [10, -25, 50] } }); + expect(r.success).toBe(false); + expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSizeOptions.1' }); + }); + + it('LIT CONTROL — the whole ruled bag parses, options included', () => { + const bag = { pageSize: 50, pageSizeOptions: [25, 50, 100] }; + const r = grid().safeParse({ pagination: bag }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data.pagination).toStrictEqual(bag); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 the FLAT shorthand — the second door onto the same renderer read. +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 object-grid flat `pageSize` shorthand carries the same accept set', () => { + for (const [label, value, code] of NOT_A_PAGE_SIZE) { + it(`should reject ${label} pageSize on the flat shorthand`, () => { + const r = grid().safeParse({ pageSize: value }); + expect(r.success).toBe(false); + expect(issues(r)).toContainEqual({ code, path: 'pageSize' }); + }); + } + + it('LIT CONTROL — the flat shorthand still parses and keeps its value', () => { + const r = grid().safeParse({ objectName: 'showcase_task', pageSize: 25, showPagination: true }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data.pageSize).toBe(25); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 THE OPENNESS PIN — what #19046 deliberately did NOT narrow. +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 the `pagination` bag stays open — the narrowing did not reach sibling keys', () => { + it('a sibling key inside the bag parses, with no `unrecognized_keys` issue', () => { + const bag = { pageSize: 25, position: 'bottom' }; + const r = grid().safeParse({ pagination: bag }); + expect(issues(r)).toEqual([]); + expect(r.success).toBe(true); + }); + + it('and it survives the parse byte-identically — passed through, not stripped', () => { + const bag = { pageSize: 25, position: 'bottom', mode: { server: true } }; + const r = grid().safeParse({ pagination: bag }); + expect(r.success && r.data.pagination).toStrictEqual(bag); + }); + + it('a bag carrying ONLY sibling keys parses — no page-size member is required', () => { + const bag = { position: 'bottom' }; + const r = grid().safeParse({ pagination: bag }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data.pagination).toStrictEqual(bag); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 CROSS-ARM — one accept set for the VALUE, two verdicts for a KEY. +// ─────────────────────────────────────────────────────────────────────────── + +describe('§4 the two arms agree on a page size and disagree on openness, both on purpose', () => { + for (const [label, value] of NOT_A_PAGE_SIZE) { + it(`both arms refuse a ${label} pageSize — the disagreement #19046 closes`, () => { + expect(PaginationConfigSchema.safeParse({ pageSize: value }).success, 'view arm').toBe(false); + expect(grid().safeParse({ pagination: { pageSize: value } }).success, 'component arm').toBe(false); + }); + } + + it('LIT CONTROL — both arms accept the same legal page size', () => { + expect(PaginationConfigSchema.safeParse({ pageSize: 50 }).success, 'view arm').toBe(true); + expect(grid().safeParse({ pagination: { pageSize: 50 } }).success, 'component arm').toBe(true); + }); + + it('an unknown KEY is refused by the view arm and accepted by the component bag', () => { + const bag = { pageSize: 25, position: 'bottom' }; + const view = PaginationConfigSchema.safeParse(bag); + expect(view.success, 'the view arm is a strictObject and stays closed').toBe(false); + expect(issues(view).map((i) => i.code)).toContain('unrecognized_keys'); + expect(grid().safeParse({ pagination: bag }).success, 'the component bag stays open').toBe(true); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index a0c788fdd7..d9e4decd64 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2501,6 +2501,21 @@ const objectBlockHistory = (type: string) => */ const FILTERS_TO_FILTER = { filters: 'filter' } as const; +/** + * A page size — a positive integer, and nothing else. + * + * ONE spelling for a rule the rest of this package already carries, so the + * component arm cannot drift from it again: `PaginationConfigSchema` + * (`view.zod.ts`) declares `pageSize: z.number().int().positive()` and + * `pageSizeOptions: z.array(z.number().int().positive())`; `MetadataQuery` + * (`kernel/metadata-plugin.zod.ts`) and the two marketplace request schemas + * (`marketplace/marketplace.zod.ts`) say `z.number().int().min(1)`. Each of + * those pins its own refusal of `0` by name. Until #19046 the `object-grid` + * door below said `z.number()` and `z.unknown()`, and was the only + * page-size declaration in the package that accepted `0`. + */ +const GridPageSizeSchema = z.number().int().positive(); + /** * `object-grid` (objectui `plugin-grid/src/ObjectGrid.tsx` @ `eb7f586b`). * Read points per key: `objectName` (throughout), `columns`/`fields` (:714-715), @@ -2629,9 +2644,53 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ + '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: z.unknown().optional() - .describe('Pagination config ({ pageSize, pageSizeOptions, … }); its presence enables paging'), - pageSize: z.number().optional().describe('Flat page-size shorthand; `pagination.pageSize` wins when both are set'), + /** + * Pagination config — the two members whose value is a PAGE SIZE bounded to + * {@link GridPageSizeSchema}, the accept set the view arm has ruled all + * along, and the bag itself left OPEN. + * + * The `z.unknown()` this door carried until #19046 was a read-point record + * of the same #7751 vintage as its `filter` and `sort` neighbours above, and + * it made the SAME authored member carry two accept sets, of which renderers + * read the looser: `PaginationConfigSchema` refuses `pageSize: 0` and pins + * that refusal by name ('should reject zero pageSize' / 'should reject zero + * values in pageSizeOptions', `view.test.ts`), while this door receipted it + * `success: true`. 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 — the view arm would have + * refused it. objectui#9896 repaired the consumer half (a resolver at every + * read point); this is the declaration half. + * + * **`z.looseObject`, not `strictObject` — the bag stays open, deliberately.** + * `PaginationConfigSchema` is itself closed, but reusing it here would + * refuse every sibling key this door has accepted since it was written — the + * `…` in its own describe says authors pass them — which is a wider + * narrowing than the defect measured above and a different decision. So what + * narrows is the accept set of a page size; what does NOT narrow is which + * keys the bag may carry. `BuildProgressFrameSchema` + * (`ai/build-progress.zod.ts`) is the house precedent for a floor-not-ceiling + * shape, and `DashboardWidgetConfigSchema` for an open bag with declared + * members. + * + * Read points measured at objectui `d18322415`: `ObjectGrid.tsx:1209` and + * `:1628` read `(schema.pagination as any)?.pageSize ?? schema.pageSize`, + * `:4179` reads `schema.pagination?.pageSize` and `:4359` + * `schema.pagination?.pageSizeOptions` — those two are the only members any + * read point on this door names, and the objectui registry has published + * this input as `type: 'object'` all along (`plugin-grid/src/index.tsx:223`), + * so a non-object value here was already answered `type-mismatch` one tier + * down while this schema accepted it. `:4175` reads presence only + * (`schema.pagination !== undefined ? true : …`), which is why an authored + * `pagination: false` used to mean paging ON. + */ + pagination: z.looseObject({ + pageSize: GridPageSizeSchema.optional(), + pageSizeOptions: z.array(GridPageSizeSchema).optional(), + }).optional() + .describe('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: GridPageSizeSchema.optional() + .describe('Flat page-size shorthand, a positive integer; `pagination.pageSize` wins when both are set'), showPagination: z.boolean().optional().describe('Show the pager (read only when `pagination` is absent)'), searchableFields: z.array(z.string()).optional() .describe('Fields the toolbar search queries; a non-empty list enables search'), From 0dcc574cdb4098c186c03de236eef8957355de11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 17:56:31 +0000 Subject: [PATCH 2/4] chore(spec): changeset and ADR-0087 semantic entry for the object-grid page-size narrowing The narrowing refuses an authored value that parsed before, so it declares the `narrowing` arm and registers the migration prescription in the ADR-0087 ledger rather than claiming a not-required category: the body carries a FROM -> TO table, which closes `no-migration-prescription` by construction. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- .../19046-object-grid-page-size-accept-set.md | 33 ++++++++++++++ ...grid-page-size-positive-integer-refused.ts | 43 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 39 +++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 .changeset/19046-object-grid-page-size-accept-set.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-grid-page-size-positive-integer-refused.ts diff --git a/.changeset/19046-object-grid-page-size-accept-set.md b/.changeset/19046-object-grid-page-size-accept-set.md new file mode 100644 index 0000000000..22f14f5f3f --- /dev/null +++ b/.changeset/19046-object-grid-page-size-accept-set.md @@ -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.** + + + +**⛔ 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. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-page-size-positive-integer-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-page-size-positive-integer-refused.ts new file mode 100644 index 0000000000..238db0489f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-page-size-positive-integer-refused.ts @@ -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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index bd4beaf896..cdeb2ff73f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -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 — `` and `` ' From 6996fd9787403d0e7290450a5539b70dca942847 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 18:02:27 +0000 Subject: [PATCH 3/4] chore(spec): regenerate the declaration, docs and strictness-ledger artifacts `check:generated` proved exactly three stale and `--fix` regenerated only those. The declaration text records the bag as `z.core.$loose`, so the published type states the openness the narrowing kept. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 4 ++-- ...2026-07-unknown-key-strictness-ledger.counts.md | 14 +++++++------- packages/spec/api-surface-declarations/ui.txt | 10 ++++++++-- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 86b8326f49..e0f0609d6f 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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` | 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) | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 7fba48d5b2..5f9330e1a5 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -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 | @@ -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 @@ -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 | @@ -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 @@ -155,7 +155,7 @@ 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 | |---|---|---| @@ -163,7 +163,7 @@ over it is here. | `app.zod.ts` | 1 | 18 | | `view.zod.ts` | 4 | 61 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **175** | +| **total** | **7** | **176** | | Bucket | Sites | |---|---| diff --git a/packages/spec/api-surface-declarations/ui.txt b/packages/spec/api-surface-declarations/ui.txt index 1b6f1460f7..4d90a7fb8c 100644 --- a/packages/spec/api-surface-declarations/ui.txt +++ b/packages/spec/api-surface-declarations/ui.txt @@ -4470,7 +4470,10 @@ declare const ComponentPropsMap: { }>; }, z.core.$strip>>>; defaultSort: z.ZodOptional; - pagination: z.ZodOptional; + pagination: z.ZodOptional; + pageSizeOptions: z.ZodOptional>; + }, z.core.$loose>>; pageSize: z.ZodOptional; showPagination: z.ZodOptional; searchableFields: z.ZodOptional>; @@ -11094,7 +11097,10 @@ declare const ObjectGridPropsSchema: z.ZodObject<{ }>; }, z.core.$strip>>>; defaultSort: z.ZodOptional; - pagination: z.ZodOptional; + pagination: z.ZodOptional; + pageSizeOptions: z.ZodOptional>; + }, z.core.$loose>>; pageSize: z.ZodOptional; showPagination: z.ZodOptional; searchableFields: z.ZodOptional>; From 8ecc9b6eda137cde62ddd6f1cd9cc5f9c9e6d690 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 18:13:37 +0000 Subject: [PATCH 4/4] chore(spec): regenerate the ui declaration shard from the merged tree `scripts/pm/os-regen-merge.sh` step 2 took main's side of `api-surface-declarations/ui.txt` (both sides moved it) and the os-regen driver merges that path with exit 0 while silently keeping one side, so the shard is re-derived here from the merged tree. The branch's delta against `origin/main` on it is now exactly the two `pagination` hunks, with main's own advance intact. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- packages/spec/api-surface-declarations/ui.txt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/spec/api-surface-declarations/ui.txt b/packages/spec/api-surface-declarations/ui.txt index 4d90a7fb8c..0a5a05e54f 100644 --- a/packages/spec/api-surface-declarations/ui.txt +++ b/packages/spec/api-surface-declarations/ui.txt @@ -230,7 +230,7 @@ declare const ActionNavItemSchema: z.ZodObject<{ params: z.ZodOptional>; }, z.core.$strict>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -242,7 +242,7 @@ declare const ActionNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -2761,7 +2761,7 @@ declare const ComponentNavItemSchema: z.ZodObject<{ componentRef: z.ZodString; params: z.ZodOptional>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -2773,7 +2773,7 @@ declare const ComponentNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -5361,7 +5361,7 @@ declare const DashboardNavItemSchema: z.ZodObject<{ type: z.ZodLiteral<"dashboard">; dashboardName: z.ZodString; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -5373,7 +5373,7 @@ declare const DashboardNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -8767,7 +8767,7 @@ declare const GroupNavItemSchema: z.ZodObject<{ type: z.ZodLiteral<"group">; expanded: z.ZodDefault; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -8779,7 +8779,7 @@ declare const GroupNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -12231,7 +12231,7 @@ declare const ObjectNavItemSchema: z.ZodObject<{ filters: z.ZodOptional>; runAction: z.ZodOptional; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -12243,7 +12243,7 @@ declare const ObjectNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -12930,7 +12930,7 @@ declare const PageNavItemSchema: z.ZodObject<{ pageName: z.ZodString; params: z.ZodOptional>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -12942,7 +12942,7 @@ declare const PageNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -19236,7 +19236,7 @@ declare const ReportNavItemSchema: z.ZodObject<{ type: z.ZodLiteral<"report">; reportName: z.ZodString; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -19248,7 +19248,7 @@ declare const ReportNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -19689,7 +19689,7 @@ declare const UrlNavItemSchema: z.ZodObject<{ _blank: "_blank"; }>>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -19701,7 +19701,7 @@ declare const UrlNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>;