fix(spec): the object-grid arm takes the page-size accept set the view arm rules (#19046) - #19095
Conversation
…he view arm rules 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 <noreply@anthropic.com>
…d 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 <noreply@anthropic.com>
…rtifacts `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 <noreply@anthropic.com>
…id-pagination-accept-set
`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 <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 472a00edf1b03d3d42762623d616dbddae9681a1 && git checkout 472a00edf1b03d3d42762623d616dbddae9681a1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 07c6f822edd9c4c48a7ce34767de420c6096b9c3 8ecc9b6eda137cde62ddd6f1cd9cc5f9c9e6d690 && git checkout -B drift-repro 07c6f822edd9c4c48a7ce34767de420c6096b9c3 && git merge --no-ff 8ecc9b6eda137cde62ddd6f1cd9cc5f9c9e6d690
node scripts/docs-audit/affected-docs.mjs --json 07c6f822edd9c4c48a7ce34767de420c6096b9c3 |
Contract reviewServed-tier: 120/120 Rendered from an isolated at-tier reviewer's verdict, ⛔ not from the commissioning seat, which measured below tier. Tier established by the reviewer as its first act by grepping its own transcript — 120 of 120 assistant turns served ① Derived judgmentsAccept-set movement — PASS, and measured as a differential rather than argued. A 53-case corpus was run through The bag stayed OPEN — PASS. A sibling-key bag parses with zero issues and deep-identical output at head and main; the built artifact carries Absence and legality — PASS. Absent Pins fail for the right reason — PASS. 19/19 at head; ablation A (the value bound removed) reds 11 and keeps 8, and ablation C (the whole file reverted to main's blob) reproduces the same 11/8 — the reds are the refusal sections, the greens are the lit controls and the openness pins. Restored blob equals No gate weakened — PASS. Zero The published schema projects the bound — PASS, and this is why the shape is a type and not a refinement. Measured on zod 4.4.3: a The migration entry and the four-way registry — PASS, structurally. The two places the change exceeds the card's literal wording — both judged in scope and adequately declared. ② Semver level
③ Boundary flagsNo security or permission boundary moves. No export is added (
Four places the implementing report overstates its evidence, corrected here rather than inherited: 「survives the parse byte-identically」 is deep-equality only (reference identity is lost and keys reorder declared-first); the implementing report's rendering of the built artifact OMITTED its Five findings in passing, none blocking: the second axis ( CI as re-read by this seat when writing this record: 30 success / 4 skipped / 1 in progress ( Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19046
Clause-②: yes
The
object-gridpage-component door declaredpagination: z.unknown()andpageSize: 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 thepaginationbag 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.0?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()ObjectGridPropsSchema(packages/spec/src/ui/component.zod.ts:2632,:2634)pagination: z.unknown().optional()·pageSize: z.number().optional()The view arm's refusals are pinned by name (
view.test.ts—should reject negative pageSize,should reject zero pageSize, and the same pair forpageSizeOptions). The corpus corroboration also holds at my base — every other page-size declaration in the package is bounded: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
paginationshould 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 onlypageSize. It looks more conservative and is measurably worse here:z.toJSONSchema()has no arm for acustomcheck. A record, the same record with a.refine(), and the same record with an aborting.refine()all project byte-identically — the mechanismpackages/spec/dropped-refinements.baseline.jsonexists to record. A refinement would have left the published JSON Schema still acceptingpageSize: 0while 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.dropped-refinements.baseline.jsonis untouched by this PR:ui/ObjectGridPropskeeps its single pre-existingfilter.elementsite and gains none.Read points, measured at objectui
d18322415(the sibling checkout in this container; the.objectui-shapin is53ded82bf):ObjectGrid.tsx:1209and:1628read(schema.pagination as any)?.pageSize ?? schema.pageSize,:4179readsschema.pagination?.pageSize,:4359readsschema.pagination?.pageSizeOptions. Across objectui's whole source,pageSizeandpageSizeOptionsare the only two members anypaginationread point names (37 + 6 reads of.pageSize, 7 + 3 of.pageSizeOptions, zero of anything else). The objectui registry declares this inputtype: 'object'(plugin-grid/src/index.tsx:223).What was NOT narrowed, and why
z.looseObject, notstrictObject: a sibling key that parsed before still parses and still survives the parse byte-identically. ReusingPaginationConfigSchemahere 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..default(25)added to the flat shorthand. The view arm has one; adding one here would change parsed output, not the accept set.pageSizeOptionsWAS 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: zeropageSizeOptionsentries outside the spec's own refusal fixtures are non-positive.One second axis, stated rather than left to be discovered
paginationmoves fromz.unknown()to an object type, so a non-object value (pagination: true) is refused where it used to parse. Measured before narrowing:paginationvalues on anobject-gridnode in either repository (thepagination: falsehits in objectui are ondata-table/object-data-table, whose props this schema does not declare, plus one internal per-group table the grid builds itself atObjectGrid.tsx:4590);type: 'object'all along, so the html tier already answeredtype-mismatchon one while this schema accepted it — the same shape thesortdocblock two members up already records;ObjectGrid.tsx:4175reads the key for presence (schema.pagination !== undefined ? true : …), which means an authoredpagination: falseused 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.pagination.pageSizerefuses zero / negative / non-integer, andpageSizeOptionsentries refuse zero / negative — each asserting the issue code and path (too_smallatpagination.pageSize), not a bare throwpageSizeparses and is preserved; the whole ruled bag parses with its optionspageSize: 25parses and keeps its valueunrecognized_keysissue, survives byte-identically (toStrictEqual), and a bag of only sibling keys parses50unrecognized_keys) and accepted by the component bag — the asymmetry, pinnedDefect reproduced in this tree, then the refusal proved able to fail. Ablation through
scripts/ablation-replace.mjs, anchorconst GridPageSizeSchema = z.number().int().positive();replaced byconst GridPageSizeSchema = z.number();(the pre-PR accept set), from the committed state: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, thengit hash-objectequal togit rev-parse HEAD:that path (d9e4decd6443…),git diff HEADempty andgit status --porcelainempty — 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.mdgrades@objectstack/spec: minor, carries the BREAKING banner,Clause-②: yes (narrowing), a FROM → TO table and the ADR-0087 disposition.scripts/check-changeset-no-major.mjsheader: "During the launch window we ship breaking changes asminor", and its end condition — "at GA … an accept-set narrowing … gradesmajor. Until then it is NOT the carrier" — withmajorrefused outright by the guard. So the rule does ⛔ not point atmajor, and there is nothing here for the maintainer floor to rule on.pr-automation.yml"WHICH LEVEL": a widening takes at leastminor, and the level axis refusespatchacross the board on a PR that declares clause ②. DeclaringClause-②: yestherefore forces at leastminor— which is where the launch-window rule already put it.registered ui-object-grid-page-size-positive-integer-refused, a new semantic entry — the fournot-requiredcategories are all refused by construction here (unpublished: spec publishes;no-migration-prescriptionandruntime-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-changesetwas never available: this moves a published accept set on a package that ships.Verdicts:
check-changeset-no-major.mjsexit 0;check-adr-0087-registration.mjsexit 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:The two that could not run, neither a pass nor a finding:
pnpm check:dual-build-cjs-loadsPREREQUISITE NOT MET — this gate reads built output, and some package has no dist/(34 packages)pnpm build; CI'sBuild Coresupplies itpnpm check:type-check-debt--re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk — @objectstack/driver-tursoturbo run build --filter='./packages/*' --filter='./packages/*/*', as lint.yml doesFour families reported
PREREQUISITE NOT METor a missing input on first run and were then made to run rather than declared:check:doc-formula-expressionsandcheck:doc-security-posture(needed@objectstack/formula+@objectstack/lintbuilt) andcheck:skill-examples(needed@objectstack/client-react's closure) all became exit 0;check:react-declaration-paritywas 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);typecheckgreen, 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--fixonly (api-surface-declarations/,content/docs/references/**, the strictness-ledger counts); theauthorable-surface.base.jsonanchor was never touched.packages/lint(ComponentPropsMap,@objectstack/spec/ui):typecheckgreen with its ledger unmoved (2 files / 6 errors / 2 pinned),test104 files / 3910 tests pass. No corpus fixture anywhere inexamples/,apps/or another package authorspaginationon anobject-gridnode, so nothing in the tree newly fails to parse.pnpm lint(eslint . --no-inline-config) — exit 0, whole tree, no narrowing claimed.pnpm check:nul-bytesexit 0, plus a direct control-character scan over all 8 changed paths — clean.origin/mainthroughscripts/pm/os-regen-merge.sh(its step 2 took main's side ofapi-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 againstorigin/mainon that shard is now exactly the twopaginationhunks, with main's own advance intact.The widening-tells reading, with its caveat
yesthe 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 throughz.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#3seat 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⚠️ And
(major, id)from the directory listing, with no index file and no positional consumer (migrations/chain.tskeys by MAJOR,MIGRATIONS_BY_MAJOR[m]), so a clean text merge cannot express a wrong meaning — the18.prefix is the protocol-major bucket, not a sequence number. The gate ispnpm --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 existing18.*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.registry.tsis deliberately NOT in themerge=os-regenregister (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/docstrees (excludingreferences/andreleases/) authoring apageSizevalue this narrowing refuses (0, negative, decimal) → ZERO. Lit control, same instrument: it does find authoredpageSizeoccurrences —content/docs/api/data-api.mdx:42(?pageSize=5) andcontent/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'spagination.pageSize—data-api.mdxdocumentspageSizeas an unknown REST query parameter refused in favour oftop/$top/limit, and the remaining pages are the metadata response shape, the object page and the metadata-plugin page. Four differentpageSizemembers, none of them this one. ⇒ nothing owed on the hand-written side; the generatedcontent/docs/references/ui/component.mdxalready 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.
Clause-②: no (narrowing)— a legal, precedented declaration (.changeset/17499-groupbyfield-non-padded.mdcarries 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: trueondata-table/object-data-tableis authored in objectui and those props are not declared inComponentPropsMapat 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
needs:contract-review, and writes no label — both carriers are the seat's write.Clause-②: yesis here because triage ruled it; ⛔ this author does not review its own clause-② verdict.packages/spec/api-surface-declarations/ui.txtmoved because the declaration text moved. PR revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #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.GOVERNED_SURFACESinscripts/pm/check-governed-merges.mjs):docs/audits/is notdocs/adr/.scaleat the renderer ceiling of 100 (#18972) #19083 landed itsscaleinstance three commits before this branch's merge base.Generated by Claude Code