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 |
…ease pair it really spans (objectstack-ai#19115) Fixes objectstack-ai#18978 Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`) and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped; the schema still ACCEPTS a record without it. Contract-review tier. `spec-changes.json`'s `aggregate.added` / `aggregate.removed` are filled by a release-time api-surface diff of the artifact being published against the previously **published** one, so they span **one release** — under a record keyed by protocol major (`from: 10, to: 17`), with every entry carrying only `since: 17` / `removedIn: 17` and `perMajor[16 → 17].added` sitting at `0` beside it. Nothing in the file distinguished one minor's slice from the whole major-boundary delta. --- ## 1 · The defect, re-measured on a real published artifact Instrument: `curl` the Release asset through the REST API, then recompute the delta with a **hand-written flattener in Python** (not this repo's code) over the two published tarballs' own `api-surface/` shard directories. | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **Release asset** `aggregate.added` | **225**, `since` counter `{17: 225}` | | same asset, `aggregate.removed` | **51**, `removedIn` counter `{17: 51}` | | same asset, `aggregate.from` / `aggregate.to` | `10` / `17` | | same asset, `perMajor[16 → 17]` | `added: 0, removed: 0` (converted 57, migrated 77) | | same asset, `release` section | **absent** (generated 2026-09-09, before objectstack-ai#18889) | | independent recompute, `npm pack` 17.3.0 vs 17.4.0 `api-surface/` | **added 225, removed 51** | | set equality, asset arrays vs recompute | `added` **True**, `removed` **True**; 0 only-in-asset, 0 only-in-recompute, both directions, both arrays | So the published arrays are, byte for byte, the **17.3.0 → 17.4.0** one-minor delta, wearing a `10 → 17` label. Cross-check: PR objectstack-ai#17080's own changeset states the same pair as "gained 225 exports and lost 51". ### One refinement to the card's premise, stated because it moves a date, not a verdict | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | `@objectstack/spec@17.3.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | npm publish time of 17.4.0 | `2026-09-09T03:57:51.929Z` | | merge time of objectstack-ai#18889 (`8b4890343`) | `2026-09-18T11:16:13+00:00` | ⇒ **no published tarball carries the mislabelled arrays yet.** The lane that will is on `origin/main` today: `release.yml` runs `release-spec-changes.sh --prepare` (line 1251) and `--verify` (1260) **before** the publish, then `--attach` (1355), and `--prepare` invokes the generator with `--previous-package`. The card's "reach is new" premise therefore holds as a property of the lane, and the first tarball to carry it is the next publish. Today's carrier is the Release-page asset, measured above. This is a sharpening, not a disproof — nothing in the card's argument depends on a tarball already existing. --- ## 2 · The A/B legs, re-taken Base: `origin/main` at `07c6f822e`. Previous artifact: `npm pack @objectstack/spec@17.3.0`, unpacked. Both legs write the real snapshot path, so each was copied out and the tree restored by `git checkout HEAD -- packages/spec/spec-changes.json` with the blob hash re-read each time (`9dbc98682…` in, `9dbc98682…` out, `git diff HEAD` empty, `git status --porcelain` empty). | leg | what ran | result | |:--|:--|:--| | **A** | HEAD generator, `--previous-package PKG_DIR` | `aggregate.added` **399** (`since` counter `{17: 399}`), `aggregate.removed` **302** (`removedIn` counter `{17: 302}`), `perMajor[16 → 17]` **0 / 0**, `release` 17.3.0 → 17.4.0 with 399 / 302 | | **B** | generator at `43f4766889e` — objectstack-ai#18889's parent, verified **0** occurrences of the string `--previous-package` on disk and 3 of `--previous-surface` — invoked with `--previous-surface` | `aggregate`, `perMajor`, `protocolVersion`, `supportFloor`, `migrateCommand` all **canonical-hash identical to leg A** (`aggregate` = `d8c3e5c4303c2ecc` on both) | Whole-document diff between the two legs: the `release` key (leg A only) and `$comment` (which objectstack-ai#18889 extended). Nothing else. ⇒ **the computation is pre-existing**, exactly as the card claimed. One reading the card did not state, and it is the sharpest one: in leg A, `aggregate.added` / `aggregate.removed` are **set-identical to `release.added` / `release.removed`**. The aggregate record does not merely resemble a one-release slice — it *is* the release slice, under a major-resolution header. --- ## 3 · ⭐ The consumer survey the card named as unmeasured **Question:** who reads `aggregate.added` / `aggregate.removed` today? ### Radius, declared | # | in radius | how read | |:--|:--|:--| | R1 | `objectstack-ai/objectstack` @ `origin/main` `07c6f822e` | `git grep -I` over tracked **and** untracked files, whole tree, no `head` anywhere | | R2 | `objectstack-ai/objectui` @ `origin/main` `05a49f2ee` (fetched for this survey) | `git grep -lI PATTERN origin/main` | | R3 | the published tarball's own contents | `npm pack` 17.3.0 and 17.4.0, plus `packages/spec/package.json` `files[]` | | R4 | the documented / prescribed consumers | `content/docs/upgrading.mdx`, `skills/objectstack-upgrade/SKILL.md` (the **published** skill catalog), `docs/adr/0087` | **Outside the radius, named as outside it:** the `objectstack-ai/cloud` repository (not checked out in this container); any third-party or private consumer of the npm artifact or of the Release-page asset; and the `spec_changes` MCP tool, which is **prose only** — `git grep spec_changes` over R1 returns docs, ADRs, changelogs and code comments and **zero implementation**, so there is nothing there to read anything. ### Instrument, in two stages - **Stage 1 — population.** Every site naming the literal `spec-changes.json`, plus every site naming a key that is distinctive to this manifest (`perMajor`, `supportFloor`). Enumerable and small; each hit was then read. - **Stage 2 — field classification.** For each member of that population, which top-level keys it actually reads. ### ⭐ Lit controls, so a zero is a reading | control | instrument | result | |:--|:--|:--| | L1 · a site that provably reads a field of `spec-changes.json`, found by stage 1 | `git grep -n "spec-changes\.json"` | **found** `packages/cli/src/utils/spec-release-changes.ts:80`, which reads `doc.release` at line 106 — a real, shipping reader | | L2 · the distinctive-key instrument is not dead | `git grep -n perMajor` / `supportFloor` | **found** the producer, two gate fixtures, and the published `skills/objectstack-upgrade/SKILL.md:219` + its `node -e` snippet at 229-236 | | L3 · the instrument reaches objectui at all | `git grep -lI PATTERN origin/main` in `../objectui` | `@objectstack/spec` → **1628** files; `api-surface` (another published spec artifact) → **3** files | ### Result | consumer | radius | reads | reads `aggregate.added` / `removed`? | |:--|:--|:--|:--| | `packages/cli/src/utils/spec-release-changes.ts:106` (ships in `@objectstack/cli`) | R1 / R3 | `doc.release` and the lengths of its four arrays | **no** | | `scripts/check-release-spec-changes.mjs` `aggregateIds()` | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId`, `release.*` | **no** | | `packages/spec/scripts/build-spec-changes.ts` `previousRelease()` (reads the PREVIOUS tarball) | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId` | **no** | | `scripts/check-adr-0087-registration.mjs` (parser-rot witness) | R1 | `migrationId` occurrences | **no** | | `skills/objectstack-upgrade/SKILL.md` — **published** to customer projects | R4 | `perMajor[].converted`, `perMajor[].migrated`, `protocolVersion`, `supportFloor` | **no** | | `content/docs/upgrading.mdx` | R4 | `.release.*`; and, for withdrawals only, `.aggregate.converted[].conversionId` / `.aggregate.migrated[].migrationId` | **no** | | whole `objectui` repository | R2 | nothing — `spec-changes` → **0** files, `perMajor` → 0, `supportFloor` → 0, `spec_changes` → 0 | **no** | | `spec_changes` MCP tool | R1 / R4 | does not exist as code | n/a | | `scripts/regen-artifacts.mjs`, `check-regen-pending.mjs`, `objectui-changeset-digest.mjs`, `check-published-files.mjs`, `docs-audit/affected-docs.mjs` | R1 | the **path**, as a ledger row — never a field | **no** | ⇒ **Zero readers of `aggregate.added` / `aggregate.removed` in the reachable radius.** Every field-level reader of `aggregate` reads `converted` / `migrated` only. Confirming probes: `git grep -nE "aggregate(\.|\[[\"'])(added|removed)"` over R1 returns **0 rows**; the loosened, case-insensitive variant returns 11 rows, all the English phrase "aggregate added to the spec" about SQL aggregate functions. **But there is a declared contract, and it is the one the defect breaks.** `content/docs/upgrading.mdx:338` says, of this very field: "The same file's `aggregate` and `perMajor` records are unchanged and still answer the **major-boundary question**." They do not. That sentence is the class-(b) contract text — a machine-readable surface that does not say what it means — and it is what makes this a defect rather than an unused field. ### Why the survey licenses the shape taken The dispatch allows two shapes: **gate** the aggregate arrays as `release.*` is gated, or **relabel** them at the resolution they actually carry. Gate-only cannot be the whole fix here, and that is a measurement, not a preference: there is no computable "correct" 10 → 17 export delta to gate against, because tarballs before protocol 15 ship no `api-surface` snapshot at all. A gate that merely refused today's shape would wedge every release until the producer changed — and the producer changing **is** the relabel. So the gate is not an alternative to the relabel; it is the **negative control for it**. And with **zero** readers, relabelling is free: nothing downstream can break, so the honest fix is available at no migration cost. That is what the survey buys. ⛔ **Not taken, and reported instead:** removing the fields, or ceasing to emit them. The survey lands exactly where the card guessed it might — nobody reads them — so the removal question is live, and it is the maintainer's. See `## Acceptance notes`. --- ## 4 · What changed A record whose export arrays are non-empty now carries the version pair they were diffed between: ```json "aggregate": { "from": 10, "to": 17, "surfaceScope": { "fromVersion": "17.3.0", "toVersion": "17.4.0" }, "added": [], "removed": [] } ``` - **`packages/spec/src/migrations/spec-changes.ts`** — `SpecSurfaceScopeSchema` + `SpecSurfaceScope`, an optional `surfaceScope` on `SpecChangesSchema`, `SurfaceDiff.scope`, and `surfaceScopeProblem(record)`, which is the refusal. The record spreads the key in rather than assigning `undefined`, so a record with no export diff serialises exactly as before. - **`packages/spec/scripts/build-spec-changes.ts`** — reads the previous version off the previous artifact's own `package.json` (`--previous-package PKG_DIR`, or the sibling of a `--previous-surface` snapshot), OMITS the arrays loudly when it cannot, and refuses outright to write a non-empty unlabelled array. - **`scripts/check-release-spec-changes.mjs`** — `verifyAggregateSurface()` recomputes the aggregate's claim from the same two tarballs the release section is checked against, and refuses an absent, mislabelled or untrue scope in both directions. Self-test roster **15 → 23** batteries. The failure headline now names which claim disagreed. - **`packages/spec/src/migrations/spec-changes-surface-scope.test.ts`** — new. - Regenerated: `packages/spec/spec-changes.json` (one line — its `$comment`) and `packages/spec/api-surface-declarations/root.txt` (+6 / -0). ⛔ **Not narrowed on purpose.** `SpecChangesSchema` still accepts an unscoped diff, because every manifest published so far carries one and a schema that refused them would narrow what an already-shipped artifact parses as. The refusal lives at the producer and at the publish gate. ⛔ **`packages/spec/src/migrations/registry.ts` was not touched** (held by objectstack-ai#19095, objectstack-ai#19090, objectstack-ai#19084, objectstack-ai#18319). The change is additive, so it declares no ADR-0087 disposition and needs no migration entry: `node scripts/check-adr-0087-registration.mjs --base origin/main` → "this PR adds no declared-breaking changeset". `scripts/regen-artifacts.mjs` (held by objectstack-ai#19024) and `content/docs/releases/**` were not touched either. The public entry barrel `packages/spec/src/migrations/index.ts` was deliberately left alone, which is why `check:api-surface` is green with no export-name churn. --- ## 5 · ⭐ Acceptance controls ### Control 1 — a test that fails on today's composition (acceptance 1) Ablation via `node scripts/ablation-replace.mjs`, which proves the mutation reached disk before running anything: ```text ablation-replace: anchor "...(surfaceDiff.scope ? { surfaceScope: surfaceDiff.scope } : {})," x1 (before) ablation-replace: anchor x1 -> x0 ablation-replace: replace "// ABLATION: the composer drops the scope..." x0 -> x1 ablation-replace: blob 2e046d0 -> d0c1189d0f233a8d46b2641812713acf33a50181 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2e046d0 -> d0c1189d0f23 VITEST_EXIT=1 Test Files 1 failed (1) Tests 2 failed | 5 passed (7) ablation-replace: blob after restore 2e046d0 ablation-replace: blob at HEAD 2e046d0 ablation-replace: ok restored: blob == HEAD (2e046d0) and `git diff HEAD` is empty ``` The reported failure is the real one: `expected 'the 10 → 17 record carries 2 added and 1 removed export(s) with no surfaceScope…' to be null`. Unablated: **7 / 7 pass**. No ablation artefact remains — restore proved by blob equality with `HEAD` and an empty `git diff HEAD`, not by an exit code.⚠️ Reported honestly: the first run of this ablation piped vitest into `tail`, so the wrapper printed `command exited 0` while the suite had failed. The run above redirects first and captures `$?` before any pipe. Only the second reading is cited. ### Control 2 — ⭐ preserved truth (acceptance 2), shown rather than asserted Same generator invocation, same real 17.3.0 tarball, before the fix and after; every record compared by canonical JSON: ```text perMajor identical=True protocolVersion identical=True supportFloor identical=True migrateCommand identical=True release identical=True aggregate MINUS surfaceScope identical=True (the only added key: {'fromVersion': '17.3.0', 'toVersion': '17.4.0'}) $comment identical=False (documents the new key) ``` And on the **committed** artifact, per-key against `HEAD`: `aggregate` unchanged, `perMajor` unchanged, `protocolVersion` unchanged, `supportFloor` unchanged, `migrateCommand` unchanged, `$comment` changed — a one-line diff (`1 insertion, 1 deletion`). The per-release section objectstack-ai#18889 added is untouched in both readings, and `composeReleaseChanges` still returns exactly its six keys (pinned in the new test). Two further preserved-truth readings: all **15** pre-existing gate self-test batteries still pass unchanged, and `pnpm --filter @objectstack/spec check:generated` reports "All 16 generated artifacts are up to date". ### Control 3 — ⭐ a negative control that distinguishes fixed from switched off (acceptance 3) The gate run against four constructed publish trees, each carrying the real committed `api-surface/` and a real `package.json`, with the real unpacked 17.3.0 tarball as `--previous`: | input | what it is | gate | |:--|:--|:--| | `good` | the post-fix generator's own output | **EXIT=0** — "release 17.3.0 → 17.4.0 verified … 399 added, 302 removed … aggregate export diff 17.3.0 → 17.4.0 verified: 399 added, 302 removed." | | `bad-prefix` | the **genuine, unmodified pre-fix artifact** — what `main`'s generator produces today | **EXIT=1** — "aggregate.surfaceScope is absent while aggregate.added/removed carry 701 export(s). … Expected { fromVersion: "17.3.0", toVersion: "17.4.0" }." | | `bad-unscoped` | post-fix output with `surfaceScope` deleted | **EXIT=1**, same refusal | | `bad-wrongscope` | `surfaceScope.fromVersion` set to `17.2.0` | **EXIT=1** — "the export diff was taken against a different release." | The `bad-prefix` row is the load-bearing one: the new gate refuses the artifact today's code actually produces, so it is a check that can still fail rather than one that was switched off. Eight further refusals are pinned as self-test batteries (absent scope, wrong `fromVersion`, wrong `toVersion`, an invented export, an omitted real removal, a claim the previous tarball could not have produced), each alongside two GREEN batteries — a matching scoped claim, and the unscoped-empty registry-only projection that must stay accepted. The producer half, both directions: ```text $ tsx scripts/build-spec-changes.ts --previous-surface ORPHAN_DIR/api-surface No aggregate export diff: the previous artifact at ORPHAN_DIR/api-surface carries no readable package.json, so the version pair the diff spans cannot be read. Omitting `added`/`removed` — an unlabelled one-release slice under the major-keyed aggregate record reads as the whole from → to delta. -> aggregate added 0 removed 0 surfaceScope None $ tsx scripts/build-spec-changes.ts --previous-surface PREV_PKG/api-surface -> aggregate added 399 removed 302 surfaceScope {'fromVersion': '17.3.0', 'toVersion': '17.4.0'} ``` ### Control 4 — the card's own numbers, re-measured after the change (acceptance 4) Instrument: HEAD generator, `--previous-package` pointed at the unpacked published 17.3.0 tarball; counters computed by `collections.Counter` over the emitted JSON. | reading | post-fix value | |:--|:--| | `aggregate.from` / `to` | `10` / `17` (unchanged — it still answers the major question for `converted` / `migrated`) | | `aggregate.added` | 399, `since` counter `{17: 399}` | | `aggregate.removed` | 302, `removedIn` counter `{17: 302}` | | `aggregate.surfaceScope` | `{fromVersion: 17.3.0, toVersion: 17.4.0}` ← **new; this is the fix** | | `perMajor[16 → 17]` | `added: 0, removed: 0` (unchanged, and now honest by construction: the record says nothing about exports) | | `release` | 17.3.0 → 17.4.0, 399 added / 302 removed (unchanged) | --- ## 6 · Verification | what | result | |:--|:--| | `pnpm --filter @objectstack/spec build` (forced fresh, under the shared verify lock) | `VERDICT command-exit 0`; `check-dts-emitted: 34/34` | | `pnpm --filter @objectstack/spec typecheck && … test` (under the lock) | `VERDICT command-exit 0` — **495 test files, 14527 tests, all passing** | | `node scripts/check-release-spec-changes.mjs --self-test` | EXIT=0 — **23 batteries pass** (15 pre-existing + 8 new) | | `pnpm --filter @objectstack/spec check:generated` | EXIT=0 — all 16 artifacts up to date | | `pnpm lint` (full repo union, at final commit `0c548868c`) | EXIT=0 — **6878 files linted, 0 errors, 0 warnings** (`--format json` counts) | | `pnpm check:nul-bytes` | EXIT=0 — 8952 text files, no raw control bytes | | `@objectstack/cli` unit tier, `src/utils/spec-release-changes.test.ts` | 6/6 pass — the one downstream reader of this artifact | | gate families derived from the diff (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`) | 103 commands over 7 paths; **43 run green** locally, listed in the report | | `pnpm check:type-check-debt` | **EXIT=3 · PREREQUISITE NOT MET — NOT MEASURED**: `--re-measure` needs the whole workspace build closure on disk and only `packages/spec` was built. Its own words: "This is NOT a pass and NOT a finding". Its non-re-measure invariants reported 0 findings on all three layers. Left to CI, which builds the closure first. | Downstream reach, with a lit control: `git grep -nE "\b(SpecChangesSchema|SurfaceDiff|SpecSurfaceAdd|SpecSurfaceRemove)\b"` outside `packages/spec` returns **0 rows**; the same instrument finds `composeMigrationChain` (a sibling export of the same module directory) in `packages/cli/src/commands/migrate/meta.ts`. ⇒ no package outside `packages/spec` names any changed declaration, so no other package's tests are owed. Export **names** are unchanged (`check:api-surface` green); only declaration text moved (`api-surface-declarations`, +6 / -0). --- ## Acceptance notes 1. ⭐ **The removal question is live, and it is the maintainer's.** The survey found **zero** readers of `aggregate.added` / `aggregate.removed` in the whole reachable radius. The card itself floats "if the answer is nobody, the cheapest honest fix may be to stop emitting it". It was ⛔ **not** implemented here — removing a published machine-readable capability is a maintainer decision — and this PR makes the surface honest instead, which is strictly compatible with a later removal. Recorded as an open question. 2. **`content/docs/upgrading.mdx` is corrected here, not merely reported.** Line 338 said 'The same file's `aggregate` and `perMajor` records are unchanged and still answer the major-boundary question'. That is true of `perMajor`, and of `aggregate.converted` / `aggregate.migrated`, which are registry-derived across the whole range — and it was never true of `aggregate.added` / `aggregate.removed`. The page was the declared contract this artefact did not keep, so correcting it is the doc half of this defect rather than opportunistic cleanup. The path was measured FREE of open-PR holders first (32 open PRs, 364 file rows, instrument lit by all four holders of the migrations registry). 3. **A deliberate boundary in the new gate, so nobody reads it as an oversight.** It refuses a *wrong* aggregate claim; it does not *require* the published artifact to make one. An aggregate with empty arrays and no `surfaceScope` is accepted, because that is the honest registry-only projection. Turning "must not lie" into "must speak" would be a new publish requirement, and that call is not this gate's. The residual hole is narrow: a bug that silently emptied `aggregate.added` while `release.added` stayed correct would pass. Worth a card if the maintainer wants the stronger rule. 4. **`--previous-surface` has no caller left in the repository.** `git grep -- "--previous-surface"` finds only the generator's own argv parsing and its docblock; every lane uses `--previous-package` (`scripts/release-spec-changes.sh:87`). It was kept working — and taught to derive its scope from the snapshot's sibling `package.json` — rather than retired, because retiring a flag is not this card. 5. **`cut-rc.yml` attaches, and never prepares.** It calls `bash scripts/release-spec-changes.sh` with no mode, which defaults to `--attach`, so the RC lane uploads the committed registry-only manifest and never runs `--verify`. Not a defect (the committed copy claims nothing), and not this card — noted because it is the one lane the new gate never sees. 6. **No label was applied by this PR.** `Clause-②: yes` means it and objectstack-ai#18978 owe `needs:contract-review`; that label is the seat's to apply and ⛔ never this branch's to clear. 7. **The two regenerated artefacts were written by the repo's own generators, never by hand.** `packages/spec/spec-changes.json` by `pnpm --filter @objectstack/spec gen:spec-changes`; `packages/spec/api-surface-declarations/root.txt` by `pnpm --filter @objectstack/spec gen:api-surface-declarations`. Both were named stale by `pnpm --filter @objectstack/spec check:generated` first, and only those two were regenerated (`--fix` is deliberately narrow). No `origin/main` merge was performed on this branch, so the `merge=os-regen` silent-resolution hazard on that path was never entered. 8. **The docs-drift bot's three hand-written rows, answered.** `content/docs/api/client-sdk.mdx` and `content/docs/kernel/contracts/metadata-service.mdx` are **still accurate**: both were anchored by a NAME COLLISION on the generic identifiers `fromVersion` / `toVersion` between this PR's new published-version STRINGS and the REST metadata-history routes' INTEGER version parameters (`rest-server.ts:8209` reads `body.toVersion` for `POST /meta/:type/:name/rollback`; `client-sdk.mdx:229-230` spells the SDK keys `from` / `to`; `metadata-service.mdx:87` declares `version: number`). Neither page mentions `spec-changes` at all. `content/docs/upgrading.mdx` is the one genuinely-mine row and is corrected in this PR. ⛔ `content/docs/releases/v17/17-1.mdx` is release-owned and was not edited — it is also **not wrong**: the same collision put it there, its only mention of the route is line 294 in a security context, and it never names `spec-changes`. 9. **The bot's own blind spot, answered by reading rather than by trusting its run.** It declared that `api-surface-declarations/root.txt` and `spec-changes.json` yielded no anchor, so pages documenting those are outside its run — and `spec-changes.json` is this card's subject. A full read of `content/docs/**`, `docs/**` and `skills/**` finds **exactly one** page stating a claim about the aggregate export arrays' resolution: `upgrading.mdx:338`, corrected here. The published `skills/objectstack-upgrade/SKILL.md` points only at `perMajor[].converted` / `perMajor[].migrated` / `protocolVersion` / `supportFloor` — all unaffected and all still true. `content/docs/releases/v15.mdx:521-523` claims only that the file is generated, ships and attaches: still accurate. `docs/adr/0087:210-213` states no falsehood (its 'compose' claim is about the registry-derived arrays), though it is where the ambiguity originates — a governed-surface question, left to the maintainer. <sub>⚠️ Notes 2, 7, 8 and 9 were written into this body by the dispatching seat (`Seat: domain:spec#3`, `session_019srGWGCBBCBHqcDoRZpQRh`) at 2026-09-18T21:06Z, from the implementing dev's final report. The dev correctly refused to PATCH this body: `.claude/agents/os-dev.md:56` says the PR body is written once, on the call that opens the PR, and later corrections are named in the report for the seat to write — and `:184` makes that clause govern over any dispatch word. ⛔ Nothing else in this body was touched, and ⛔ no verdict about the diff is written here: the clause-② review is an isolated at-tier reviewer's, and `needs:contract-review` stays on both carriers until it lands.</sub> --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…nd inside a previously-z.unknown() bag is not a new key (objectstack-ai#19153) Fixes objectstack-ai#19099 Clause-②: no — one file under `scripts/pm/**`. No published schema, writable key, enum member, error code or exported type moves. `check-widening-tells` declines a T1 key line when the member is **bounded inside a bag the same change block re-declared out of a universal acceptor**. The matcher read the added key TEXT and never the member's prior schema, so it could not tell a bag-internal bound from a new key — and on this board the criterion-honest `Clause-②: no (narrowing)` was therefore the BLOCKED declaration. --- ## ⛔ Body replaced by the seat at 2026-09-20T15:55Z, for head `95a7e8c0bf` — the FOURTH version, and this one exists because the PR got SMALLER Ruling **D′** (`5748934308`, batch objectstack-ai#192 item 4) cut this PR down. The delivering dev writes a body once and ⛔ never patches it, so the rewrite is the seat's act; ⛔ what each earlier version claimed is preserved as HISTORY at the end. **+937 / −43 → +292 / −27.** One file, 319 changed lines. ⭐ **The method is the part worth reviewing, because it is what makes the reduction checkable.** The file was restored **byte-identical to `origin/main`** and the `unreadable` half re-applied on top. ⇒ what remains is *provably* only that half. ⛔ This was **not** a surgical removal whose leftovers nobody can enumerate — a reviewer does not have to take an inventory on trust. ### LANDED `enclosingDelimiters` answers the whole shown stack beside an `unreadable` flag, with the two sound discriminations: a `*/` outside a block comment says the hunk **began** inside one, so the comment-pushed frames are discarded; and a lone `/` is the division it is. ### DROPPED — enumerated, ⛔ not summarised the bag-internal decline · `samePlace` / `framePlace` / `shownPlace` / `placeAt` · the place parameters on `replacesUniversalAcceptorKey` · the removed-run reading on `declaresUniversalAcceptorKey` · the `removedPlaces` plumbing in `tellsInFile` · the 67-case `objectstack-ai#19099` battery.⚠️ **The last two of those go beyond a literal reading of 「drop the bag decline」, and the dev said so rather than letting it pass as scope.** The seat's answer, on the record: **the dev read it correctly and the wider reading is the right one.** The removed-run reading would make objectstack-ai#18234's existing decline *quieter* in a class it did not previously cover — a behaviour change D′ does not authorise, and one that would have to be judged on its own evidence. ⇒ dropping it is also exactly what lets the corpus below read **0 differing rows**. If objectstack-ai#18234's overturn condition is wanted, it is a **separate, additive** change on its own card; ⛔ it does not ride this one. ## The two round-3 defects the ruling named — both discharged 1. **The false header sentence.** The residual paragraph is rewritten and now states **both** triggers with the rate each occurs at, re-measured on this tree. ⛔ The earlier sentence called one shape 「the whole residual」; naming one while another dominates is the precise failure being corrected. 2. **`!openedBlockComment` was pinned by nothing.** It is now pinned by one case that goes red when the conjunct is removed — ablation leg 1, **1 of 525**. **Ruling item 3** — the shape's disposition is carried in `REFUSAL_SENTENCE`, so a refused author reads why the gate fires and that the remedy is the tier. Verified end to end: the live pair exits **4** and prints it; the same diff declared `Clause-②: yes` exits **0**. ## 🔴 WITHDRAWN by the seat — the residual measurement was a SHALLOW-CLONE ARTEFACT, and the card's premise was right all along ⛔ **This section previously claimed the re-measured residual 「DISAGREES with this card's own premise」 and offered a two-populations reconciliation. Both are withdrawn.** Round-4 at-tier review (record `5751047556`) falsified them, and the seat wrote the withdrawn text, so the withdrawal is the seat's act and is recorded here rather than quietly deleted. **The artefact.** The corpus replay ran over a clone whose history is **shallow**. Git diffs a grafted boundary commit **against the empty tree**, so `ae8edd2c4f` (the boundary in `.git/shallow`, 2026-08-31) rendered locally as **「1092 files changed, 309028 insertions(+)」** — 692 non-test `.ts` files under `packages/spec/src`. Via the API that commit is really **2 files, +41/−7**, a docs gloss. ⇒ **3141 of the 3143 flagged rows came from that one phantom diff.** Every regex-slash trigger sat on an added line of a diff that does not exist. **The same measurement with every shallow boundary excluded** (real history, same tip): | | withdrawn reading | corrected reading | |---|---|---| | commits / file diffs | 436 / 1841 | **435 / 1149** | | corpus identity | 14295 = 14295, 0 differing | **484 = 484, 0 differing** (T2 125 · T1 270 · T4 89 — the gate’s read path, confirmed round 5) | | T1 rows | 9482 | **270** — ⭐ round 5 closed the 265-vs-270 gap in favour of 270 | | `unreadable` | 3128 (**33.0%**) | **2 (0.7%)** | | regex-slash trigger | 3069 rows / 72 lines | **0** | | apostrophe trigger | 59 rows / 3 lines | **2** — `approval.zod.ts:884` `Entra's` · `solution-blueprint.zod.ts:190` `object's`, both JSDoc prose | ⭐ **The corpus-identity claim SURVIVES** — 0 differing rows on both populations, and it is the load-bearing one: the reduced diff still moves no tell anywhere. What died is the *residual rate*, which is the one thing ruling D′ item 1 ordered corrected.⚠️ And the 「59 apostrophe rows from 3 lines」 was wrong **in kind**, not only in size: 2 were apostrophes, and **57 came from a single CODE line** in the phantom diff (`api/contract.zod.ts`, a nested template literal) whose first trigger is the type-blind pop, ⛔ not an apostrophe at all. ⇒ **The stand-down disposition's reading stands** (apostrophe live, regex 0 of 250). There were never two true populations — there was **one broken instrument**. The seat repeated the dev's framing to the board before this review ran, and that was wrong. ⛔ This is precisely the trap `AGENTS.md` routes through `scripts/pm/git-history.mjs`: **a shallow clone answers at exit 0 with no warning.** A corpus reading taken without excluding `.git/shallow` is not a small overcount — it is a reading of a diff that never happened. ~~**Owed on the next head, ⛔ not fixed in this body:** the file's own header paragraph still carries the withdrawn numbers and tells the next author 「THE NUMBER THAT MATTERS is the 33%」 and that a declining reader would refuse on a third of all T1 rows. On real history that is **0.7%**. That paragraph is the dev's to rewrite (review items 1–3); this body is the seat's and is corrected here.~~ ⇒ ✅ **DISCHARGED at head `0d69cf2e8d`** — the header paragraph was rewritten in round 5. Detail below. ##⚠️ The flag has no suppressing reader, and none was manufactured With the decline out, `enclosingDelimiter` deliberately collapses `unreadable` to null for its two firing-only callers — an existing, reasoned, three-rounds-old decision the dev did not touch. **Disclosed at the definition and in the header** rather than left for the next author to find. A reviewer may reasonably read the exported flag as infrastructure without a consumer. The counter-argument, stated so the review can weigh it rather than discover it: the flag is load-bearing **inside** the walk — it is the `!unreadable` guard that keeps the reset from discarding real frames (ablation leg 3) — and the reset itself does reach a caller (the `inParameterList` case, ablation leg 4). ## Measured 🔴 **CORPUS — the numbers below are WITHDRAWN; the live values are in the withdrawal section above.** Round 5’s review (`5751378085`) caught this section still asserting `436 / 1841 / 14295 = 14295` as a **live claim**,⚠️ **positioned ABOVE the withdrawal that retracts it** — so a reader met the false figures first and the retraction second. That is the seat’s defect, ⛔ not the dev’s, and it is corrected here rather than deleted. **The measured values, on the gate’s own read path**: **435** non-merge commits (one shallow boundary excluded), **1,149** file diffs of non-test `.ts`, identity **484 = 484 rows, 0 differing commits** (T2 **125** · T1 **270** · T4 **89**), 1,133 of 1,149 blobs resolved. ⭐ The **claim** this section exists to make is unchanged and survives every instrument tried: **0 differing rows** — the reduced diff moves no tell anywhere on the corpus. **ABLATION — 8 legs, ⛔ all red**, through `scripts/ablation-replace.mjs`, which verifies the mutation on disk and restores; every restore proven twice (`git diff HEAD` empty AND `git hash-object` equal to `git rev-parse HEAD:path`): | leg | mutation | red | |---|---|---| | 1 | `!openedBlockComment` removed from the reset guard | 1/525 — the ruling's second round-3 defect | | 2 | `!leadingCommentClosed` removed | 1/525 | | 3 | `!unreadable` removed | 1/525 — the guard protecting real frames | | 4 | reset condition forced false | 3/525 | | 5 | lone-slash discrimination widened so every slash flags | 1/525 | | 6 | unterminated-string branch made to swallow | 2/525 — includes THE RESIDUAL case | | 7 | type-blind pop flag deleted | 1/525 | | 8 | disposition removed from `REFUSAL_SENTENCE` | 1/525 | **PROBES, each with its `origin/main` control leg.** The live pair (objectstack-ai#19095's shape) exits **4** with 2 T1 rows at the bag members — identical to main's 2 rows. objectstack-ai#18234's sibling decline still exits **0**. The same diff declared `Clause-②: yes` exits **0** and is never blocked.⚠️ **One probe did NOT return what the dispatch asked it to confirm, and that is reported, not reconciled.** The surplus control — a genuinely new outer key beside a re-declared bag — reads **2 rows on BOTH legs**, ⛔ not the **1 row** the dispatch's ZONE 2 asked for. The reason is sound: 「1 row」 was a property of the **dropped** decline, so with the decline gone the reading is main's, and both legs agreeing is the correct outcome. ⇒ the dispatch's expectation was written for the larger diff and did not survive the reduction. ## ⛔ Incidents, reported rather than tidied away ⭐ These are in the body on purpose. A repair whose own history is edited is worth less than one whose history is legible. 1. **Commit `d603731b4f` does not parse.** Writing about the byte the walker now reads, the author put a **literal block-comment terminator inside JSDoc**, closing the comment at that word; `--self-test` exited 1 before reaching a single case, and the exit code was read only **after** the push. Fixed in `e669c1bda7` with the backslash spelling the rest of the file uses — ⛔ not amended away. 2. **One ablation leg ran while its new pin was still UNCOMMITTED.** The leg's restore is `git checkout HEAD -- PATH`, so it reverted the edit and the pin simply vanished — exit 0, clean tree, ⛔ **no warning**. Re-applied, committed, re-run from the committed state. 3. ⭐ **One leg came back GREEN, and that is how a missing pin was found** — the `!unreadable` guard was pinned by **nothing**. Recorded loudly rather than quietly re-run; the case that pins it now fails its leg. 4. **This round, leg 5 ran VOID on its first spelling and is reported rather than quietly re-run.** The replacement text was a substring already present 4 times, so the tool refused it for a rise of 0 and the self-test never ran. Re-spelled distinguishably, then red. ## Verification `dispatch-gates --commands` re-derived **in this worktree from the REAL changed paths** → **30 families, all exit 0**, each code captured into a file **before any pipe**, reconciled with `--ran` carrying `command :: exit N` for all 30: **30 derived, 30 run, 0 NOT-MEASURED, 0 UNRUN** ⇒ the tool reports that zero as **DERIVED**, not claimed. `--self-test` exit **0, 525 cases** (508 is `origin/main`'s count; +15 the new `objectstack-ai#19099` battery, +2 the refusal-sentence cases). Battery floors: new roster entry at **15**; the refusal-sentence battery raised **11 → 13**; the 67-case bag battery deleted with its decline. `eslint . --no-inline-config` at this head: **6943 files, 0 errors, 0 warnings** — the WHOLE population, so ⛔ no narrowing is claimed and none is owed. `check:nul-bytes` exit 0, plus an independent `grep -naP` over the control-character class on the edited file: exit 1, no match.⚠️ **One correction, stated rather than buried.** `pnpm check:pm-dispatch-gates` first returned **124** — the dev's own 300s then 540s timeout cap, ⛔ **not a red**. Re-run detached to completion: exit **0**, 1883 cases, 853.4s. Collected by a foreground blocking wait on the recorded PID, ⛔ never by a watcher. ⭐ **STALE TREE discharged with a ROSTER, not a count.** `origin/main` was merged into the branch **exactly once** (`8271c8142`, 15:01Z, clean, 0 conflicts). It moved twice more during the round (`a88a9733`, `3ff6ddf1`) and was ⛔ deliberately **not** re-merged; the 30-family roster was re-derived against the newer main and diffed against the first derivation — **byte-identical**, so the upstream move added and removed no family. ## NOT MEASURED, declared objectui's mirror under any matcher · the place limits of `rewritesExistingOpener` and `respellsExistingClosedSetBinding` — ⛔ probed by nobody in any round, stated as a gap rather than a verdict · merge-queue convergence · CI convergence, which is the seat's to read · **whether a reader that DECLINES on these frames is viable at all**: on real history it would refuse on **0.7%** of the T1 rows this gate reports (2 of 270) — ⛔ not the 33.0% this body claimed before round 4 falsified it as a shallow-clone artefact (see the withdrawal above), which is a design constraint on whoever takes the parsed-schema comparison the ruling points at. ⛔ Not a defect of anything that exists today — nothing suppresses on the flag — so it is not a filing class; it is written into the header at the definition so it cannot be missed. **Attribution (prose, because the edit side of a PR-body write always appends its own footer):** this body was written by the `domain:spec` PM **seat 3** in session `session_01HnRAeVTLJevtQ5iCPX6JSm`; the change was implemented by the dispatched dev on branch `claude/issue-19099-widening-tell-prior-schema`. The three earlier versions were written by seat session `session_01AmH9bKvGoLjiY86Q4Z3og2`. --- ### Round 6 — the harness was fixed before the numbers, and that is the finding Head **`059f5e9aafa0`**, prose-only again: **0 non-comment changed lines**, comment-stripped file byte-identical at **197,712 bytes**, self-test unchanged at **525**. Gates **30/30 exit 0**, roster byte-identical to round 5's. ⭐ **The root cause, reproduced directly rather than inferred.** `ROOT` in this module is `fileURLToPath(new URL('../..', import.meta.url))`. The round-5 rig kept module copies in a scratchpad **outside any worktree**, so `gitAt` ran `git -C /tmp/… cat-file` — which fails, **is caught, and answers `null` with nothing raised**. Probed both ways at this head: the *same* `headBlobSource` call reads ****336,382 code units** (⚠️ 「bytes」 was loose — it is a UTF-16 `.length`; the round-6 review located the blob: `packages/spec/src/ui/view.zod.ts`, 339,808 bytes on disk)** from a copy inside the worktree and returns **`null`** from the copy outside it. ⇒ two silent defects, ⛔ not six wrong figures: | | effect | |---|---| | `tellsInFile` alone, without `wideningTells(files)` | drops `ledgerRowLicences` (objectstack-ai#17300) ⇒ every ledger row in `migrations/registry.ts` tells ⇒ **T2 inflates** by 243 | | `readSource` answering `null` | drops objectstack-ai#18702 file-local factory resolution ⇒ **T1 deflates** by 5 |⚠️ And the earlier claim that looked like a control was not one: 「0 unresolved」 was **true about line indices** and said nothing about blobs. ⇒ **The rig now asserts resolution on a known blob before it counts anything, and refuses to report if it comes back empty.** **No disagreement this round, and re-derived rather than transcribed.** Every reviewer figure came back identical on the dev's own instrument once it ran the gate's read path: **484 = 484, 0 differing** (T2 **125** · T1 **270** · T4 **89**), **1,133 of 1,149** head blobs resolved, 22 unresolved on both legs, `unreadable` **2 of 270 (0.7%)**, comment-start hunks **35 of 270 (13.0%)**, **1,939** non-empty of 2,298 sides. Both apostrophe sites confirmed a **third** time. ⇒ **270 is the better reading** for the stated reason: the paragraph claims to describe 「the T1 rows this gate REPORTS」, so it has to be true of **the gate**, and 265 answered a different question. ⭐ **What the file gained beyond the six corrections:** the horizon paragraph now carries **the read path as part of the horizon**. A census must call `wideningTells` with `headBlobSource` live, because `tellsInFile` alone drops the licences and a null `readSource` drops factory resolution — and because `ROOT` comes from `import.meta.url`, a copy imported from outside a worktree fails every `cat-file` silently. ⇒ it now tells a census to **assert its own resolution before it counts**.⚠️ **One gate incident, disclosed and handled the expensive way.** The dev's first family pass returned **exit 3 on six families** — 「PREREQUISITE NOT MET — the dependency yaml is not installed」 — because it recreated the worktree and measured before `pnpm install`. Read as **NOT MEASURED, ⛔ never as a red**. It then re-ran **all 29** short families from scratch rather than only the six, and re-ran the long family post-install (exit 0, 1883 cases, 860.1s) rather than **reasoning about whether** the pre-install run that had already exited 0 was degraded. ⭐ That is the right instinct: a reading whose conditions changed is re-taken, ⛔ not argued about. ## HISTORY — the two justifications this body used to carry, both REFUTED 1. **Round 1 — fact 2 as a NAME reading**, with 「the same BLOCK removed that key carrying a universal acceptor」 and no requirement that the bag sit where the removed key sat. Refuted by three git-emitted diffs. 2. **Round 1 — 「this one leaves no 'but this member widens' case open」.** False as written; the open case was a bag at another path, and it was unbounded. 3. **Round 2 — the place as opener + head TEXT.** Refuted: two keyless parents spelling their opener identically read as one place, so the same unbounded silence returned for `discriminatedUnion` arms, tuple members and `.or()`/`.and()` arguments. 4. **Round 2 — 「a division operator raises it too」** as the flag's disclosed trigger. Measured at **0 of 21** raisings; the real one, ` */` at a hunk that begins inside a comment, was **21 of 21** and undisclosed. 5. **Round 2 — 「byte-identical」** for the firing-only readers. Textually false (`enclosingDelimiter`'s body differs by two lines); the true and now-stated claim is **answer-identical**, over 431,750 side-lines. ### Round 5 — the correction landed, and it carries TWO unresolved disagreements rather than a reconciliation Head **`0d69cf2e8d`**, **prose-only and proven two ways**: no changed line is a non-comment line, and the comment-stripped file is **byte-identical** to `95a7e8c0` at **197,712 bytes on both sides**. Self-test stays **525/525**; the 30-family roster diffs byte-identical against round 4's; all 30 exit 0. ⭐ **F1 was confirmed on the dev's OWN instrument, ⛔ not adopted from the review.** `cat .git/shallow` names exactly one boundary, `ae8edd2c4f`; locally it renders against the empty tree at **1,092 files / 309,028 insertions** scoped to `packages/spec/src` — byte-identical to the reviewer's figure — while the API reports **2 files, +41/−7**. Two instruments, one finding, agreeing on the load-bearing facts: the boundary sha, its phantom render, 1,149 file diffs, **0 differing commits**, `unreadable` = **2**, regex-slash = **0**, and **both apostrophe sites file for file**.⚠️ **Two numbers still disagree, and they are printed rather than smoothed** — the instruction was to report a disagreement, ⛔ never to reconcile to the reviewer: | | dev (round 5) | reviewer (round 4) | |---|---|---| | corpus identity rows | **722 = 722** (T2 368 + T1 265 + T4 89 — *every* tell kind) | **484 = 484** | | T1 denominator | **265** | **270** | | `unreadable` rate | 2 of 265 = **0.8%** | 2 of 270 = **0.7%** | The numerator is **2 on both sides** and both name the same two JSDoc-prose apostrophes, so ⛔ nothing load-bearing turns on this. The dev checked the obvious gap on its own side — **all 265 rows resolved an index, 0 unresolved** — so the residual **5 rows are unexplained from here**, and it declined to quote a figure it had not measured. The file carries **265** with its population spelled out so the number is checkable. ⇒ round 5's verification owns closing it. ### 🧾 Two prose figures moved that the review did not name — and the seat's answer is that moving them was RIGHT The dev flagged this itself against a bound that read 「nothing else in the code moves」. ⛔ Nothing executable moved for either. 1. 「` */` fired on **24%** of readable T1 stacks」 — rested on the same contaminated corpus as the four phrases the review ordered deleted. ⭐ The dev did **not** restate round 1's number, because it could not re-measure round 1's behaviour; it replaced an **unverifiable rate** with **the population that rate is about**, measured on the clean corpus: a hunk that begins inside a comment is **34 of 265 T1 rows (12.8%)**. 2. 「431,750 side-lines (1,881 sides of 250 real commit diffs)」 — re-measured as **84,924 side-lines over 2,298 sides** of the 1,149 file diffs, **0 disagreements** for each firing-only reader. **Seat's ruling, on the record:** ⛔ leaving a knowingly-false rate in the file **during the round whose entire purpose is removing false rates** would be incoherent, and the bound's word was *code*, which did not move. ⭐ And (1) is the better instinct: when a rate cannot be re-measured, the honest replacement is the **measured population**, ⛔ not a deleted sentence and ⛔ not a carried-over number. Both stand.⚠️ One sentence the file now makes explicit, worth naming because it is the trap this whole arc was: the regex-slash **zero** must ⛔ **NOT** be read as 「regex literals are rare here」. They are common. The flag is deliberately conservative, and a zero on a real corpus is a fact about **this window**, ⛔ never about the shape. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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