Skip to content

fix(spec): the object-grid arm takes the page-size accept set the view arm rules (#19046) - #19095

Merged
os-elon-musk merged 5 commits into
mainfrom
claude/issue-19046-grid-pagination-accept-set
Sep 18, 2026
Merged

os-elon-musk merged 5 commits into
mainfrom
claude/issue-19046-grid-pagination-accept-set

Conversation

@os-elon-musk

@os-elon-musk os-elon-musk commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19046

Clause-②: yes

The object-grid page-component door declared pagination: z.unknown() and pageSize: z.number(), so the same authored member carried two accept sets and renderers read the looser one. This bounds the page-size members to the accept set the view arm has ruled all along, and deliberately leaves the pagination bag open.

The premise, re-derived by symbol at this branch's base (362035cc0)

⛔ No line number inherited from the card — triage warned about exactly that, and the card's own reading was taken on abb01f1.

arm symbol declaration at my base accepts 0?
view PaginationConfigSchema (packages/spec/src/ui/view.zod.ts:867-868) pageSize: z.number().int().positive().default(25) · pageSizeOptions: z.array(z.number().int().positive()).optional() no
grid component ObjectGridPropsSchema (packages/spec/src/ui/component.zod.ts:2632, :2634) pagination: z.unknown().optional() · pageSize: z.number().optional() yes — both

The view arm's refusals are pinned by name (view.test.ts — should reject negative pageSize, should reject zero pageSize, and the same pair for pageSizeOptions). The corpus corroboration also holds at my base — every other page-size declaration in the package is bounded:

packages/spec/src/ui/view.zod.ts:867           z.number().int().positive().default(25)
packages/spec/src/ui/view.zod.ts:868           z.array(z.number().int().positive())
packages/spec/src/ui/component.zod.ts:2634     z.number()                               ** the outlier
packages/spec/src/marketplace/marketplace.zod.ts:435   z.number().int().min(1).max(100).default(20)
packages/spec/src/marketplace/marketplace.zod.ts:456   z.number().int().min(1)
packages/spec/src/kernel/metadata-plugin.zod.ts:399    z.number().int().min(1).max(500).default(50)
packages/spec/src/kernel/metadata-plugin.zod.ts:429    z.number().int().min(1)

PR #18638, which held this file, is merged (2026-09-18T16:01:37Z) and did not tighten it in passing, so triage's downgrade clause does not apply.

The shape decision — a permissive object, and the evidence that chose it

The card's complaint is that the two arms disagree about a page size. It is ⛔ not that pagination should become a closed shape. Two shapes were plausible; the evidence is one-sided.

Chosen: z.looseObject({ pageSize, pageSizeOptions }) — validates the two declared members, passes every other key through.

Rejected: z.unknown() plus a refinement judging only pageSize. It looks more conservative and is measurably worse here:

  • z.toJSONSchema() has no arm for a custom check. A record, the same record with a .refine(), and the same record with an aborting .refine() all project byte-identically — the mechanism packages/spec/dropped-refinements.baseline.json exists to record. A refinement would have left the published JSON Schema still accepting pageSize: 0 while the parser refused it, and it would have needed a new row in that shrink-only ledger, which is a ratchet this dev may not raise.
  • The loose object is a type narrowing, so it projects. Measured on the built artifact:
packages/spec/json-schema/ui/ObjectGridProps.json
  pagination.properties.pageSize                 { "type": "integer", "exclusiveMinimum": 0 }
  pagination.properties.pageSizeOptions.items    { "type": "integer", "exclusiveMinimum": 0 }
  pagination.additionalProperties                {}          ** the bag stays OPEN
  pageSize                                       { "type": "integer", "exclusiveMinimum": 0 }

dropped-refinements.baseline.json is untouched by this PR: ui/ObjectGridProps keeps its single pre-existing filter.element site and gains none.

Read points, measured at objectui d18322415 (the sibling checkout in this container; the .objectui-sha pin is 53ded82bf): ObjectGrid.tsx:1209 and :1628 read (schema.pagination as any)?.pageSize ?? schema.pageSize, :4179 reads schema.pagination?.pageSize, :4359 reads schema.pagination?.pageSizeOptions. Across objectui's whole source, pageSize and pageSizeOptions are the only two members any pagination read point names (37 + 6 reads of .pageSize, 7 + 3 of .pageSizeOptions, zero of anything else). The objectui registry declares this input type: 'object' (plugin-grid/src/index.tsx:223).

What was NOT narrowed, and why

  • Sibling keys inside the bag. z.looseObject, not strictObject: a sibling key that parsed before still parses and still survives the parse byte-identically. Reusing PaginationConfigSchema here would have refused every one of them — the … in this door's own describe says authors write them — which is a wider breaking change than the card's premise and a different decision. §3 of the new pin is what makes that auditable; §4 records the deliberate asymmetry (the view arm stays closed, this bag stays open), so a future author harmonising the two arms reds a case instead of discovering the consequence in a renderer.
  • No .default(25) added to the flat shorthand. The view arm has one; adding one here would change parsed output, not the accept set.
  • pageSizeOptions WAS bounded, and that is a judgement I am naming rather than burying. It is the same defect class by a second door: pageSizeOptions: [0, 25] puts a zero entry in the page-size selector, which sets the fetch window to zero rows — the card's exact failure. Its shape was already pinned by the view arm (z.array(z.number().int().positive())), whose zero/negative refusals are pinned by name, and its read point is measured above. Corpus cost: zero pageSizeOptions entries outside the spec's own refusal fixtures are non-positive.

One second axis, stated rather than left to be discovered

pagination moves from z.unknown() to an object type, so a non-object value (pagination: true) is refused where it used to parse. Measured before narrowing:

  • zero non-object pagination values on an object-grid node in either repository (the pagination: false hits in objectui are on data-table / object-data-table, whose props this schema does not declare, plus one internal per-group table the grid builds itself at ObjectGrid.tsx:4590);
  • the registry has published type: 'object' all along, so the html tier already answered type-mismatch on one while this schema accepted it — the same shape the sort docblock two members up already records;
  • ObjectGrid.tsx:4175 reads the key for presence (schema.pagination !== undefined ? true : …), which means an authored pagination: false used to turn paging ON. That value now gets a located refusal instead of the opposite of what it says.

Pins, each with its control

New file: packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts — 19 cases, 4 sections.

section asserts control
§1 pagination.pageSize refuses zero / negative / non-integer, and pageSizeOptions entries refuse zero / negative — each asserting the issue code and path (too_small at pagination.pageSize), not a bare throw two LIT CONTROLS: a legal pageSize parses and is preserved; the whole ruled bag parses with its options
§2 the flat shorthand carries the same accept set, by name a LIT CONTROL: pageSize: 25 parses and keeps its value
§3 a sibling key in the bag parses with no unrecognized_keys issue, survives byte-identically (toStrictEqual), and a bag of only sibling keys parses this section IS the control for the trap above
§4 both arms refuse the same three non-page-sizes, and both accept 50 an unknown KEY is refused by the view arm (unrecognized_keys) and accepted by the component bag — the asymmetry, pinned

Defect reproduced in this tree, then the refusal proved able to fail. Ablation through scripts/ablation-replace.mjs, anchor const GridPageSizeSchema = z.number().int().positive(); replaced by const GridPageSizeSchema = z.number(); (the pre-PR accept set), from the committed state:

ablation-replace: ok mutation landed: anchor 1 -> 0, blob d9e4decd6443 -> 462c333a1bda
  Test Files  1 failed (1)
       Tests  11 failed | 8 passed (19)
  FAIL §1 ... > should reject zero pageSize
  AssertionError: expected true to be false     ** parse({ pagination: { pageSize: 0 } }) SUCCEEDS
ablation-replace: ok restored: blob == HEAD (d9e4decd6443) and `git diff HEAD` is empty

The 11 that reddened are exactly §1/§2/§4's refusals; the 8 that stayed green are the lit controls and §3's openness pins — the right partition, since the ablation removed only the value bound. Restored again through the explicit form: git checkout HEAD -- packages/spec/src/ui/component.zod.ts, then git hash-object equal to git rev-parse HEAD: that path (d9e4decd6443…), git diff HEAD empty and git status --porcelain empty — and the pin re-run green (19/19) from the restored tree.

Changeset — the derivation, quoting the rule

.changeset/19046-object-grid-page-size-accept-set.md grades @objectstack/spec: minor, carries the BREAKING banner, Clause-②: yes (narrowing), a FROM → TO table and the ADR-0087 disposition.

  • scripts/check-changeset-no-major.mjs header: "During the launch window we ship breaking changes as minor", and its end condition — "at GA … an accept-set narrowing … grades major. Until then it is NOT the carrier" — with major refused outright by the guard. So the rule does ⛔ not point at major, and there is nothing here for the maintainer floor to rule on.
  • pr-automation.yml "WHICH LEVEL": a widening takes at least minor, and the level axis refuses patch across the board on a PR that declares clause ②. Declaring Clause-②: yes therefore forces at least minor — which is where the launch-window rule already put it.
  • Direction carriers, per the same header: the BREAKING banner plus the ADR-0087 disposition. Disposition is registered ui-object-grid-page-size-positive-integer-refused, a new semantic entry — the four not-required categories are all refused by construction here (unpublished: spec publishes; no-migration-prescription and runtime-interface-only: the body carries a FROM → TO table, and "a changeset that ships instructions for rewriting a consumer's code cannot also claim that no consumer has to rewrite anything"; type-surface-only: this is a runtime accept set on a metadata surface, not a type annotation).
  • skip-changeset was never available: this moves a published accept set on a package that ships.

Verdicts: check-changeset-no-major.mjs exit 0; check-adr-0087-registration.mjs exit 0 — 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.

Verification

Full census derived from the real change set after the changeset existed, at 8ecc9b6ed, with every exit code captured before any pipe:

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 8 path(s) vs merge base 07c6f822e, three-dot; 109 commands
  107 exit 0  ·  2 PREREQUISITE NOT MET (exit 3)  ·  0 findings

The two that could not run, neither a pass nor a finding:

family reason what it needs
pnpm check:dual-build-cjs-loads PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ (34 packages) a repo-wide pnpm build; CI's Build Core supplies it
pnpm check:type-check-debt --re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk — @objectstack/driver-turso turbo run build --filter='./packages/*' --filter='./packages/*/*', as lint.yml does

Four families reported PREREQUISITE NOT MET or a missing input on first run and were then made to run rather than declared: check:doc-formula-expressions and check:doc-security-posture (needed @objectstack/formula + @objectstack/lint built) and check:skill-examples (needed @objectstack/client-react's closure) all became exit 0; check:react-declaration-parity was run as CI runs it (MANIFEST="$PWD/sdui.manifest.json" … --strict) and reports no new declaration divergence vs the accepted baseline.

Beyond the census:

  • pnpm --filter @objectstack/spec test — 495 files / 14539 tests pass (post-merge); typecheck green, test-layer ledger unmoved at 54 files / 259 errors / 144 pinned signatures.
  • pnpm --filter @objectstack/spec check:generated — all 16 generated artifacts up to date. Three were proved stale and regenerated with --fix only (api-surface-declarations/, content/docs/references/**, the strictness-ledger counts); the authorable-surface.base.json anchor was never touched.
  • The one in-repo consumer of the changed surface is packages/lint (ComponentPropsMap, @objectstack/spec/ui): typecheck green with its ledger unmoved (2 files / 6 errors / 2 pinned), test 104 files / 3910 tests pass. No corpus fixture anywhere in examples/, apps/ or another package authors pagination on an object-grid node, so nothing in the tree newly fails to parse.
  • Repo-wide pnpm lint (eslint . --no-inline-config) — exit 0, whole tree, no narrowing claimed.
  • pnpm check:nul-bytes exit 0, plus a direct control-character scan over all 8 changed paths — clean.
  • Merged origin/main through scripts/pm/os-regen-merge.sh (its step 2 took main's side of api-surface-declarations/ui.txt, which both sides moved, and step 3's hook held the regeneration debt until it was discharged). This branch's delta against origin/main on that shard is now exactly the two pagination hunks, with main's own advance intact.

The widening-tells reading, with its caveat

node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF   -> exit 0
✓ the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already
  routes to contract review, so a tell on top of it decides nothing.

⚠️ That exit 0 is the absence of a reading, not a clean one. With yes the gate short-circuits and examines no file. Run as a diagnostic only with --declaration no, it exits 4 on two T1 tells: component.zod.ts:2689 (pageSizeOptions) and :2692 (pageSize) — "a new key on a Zod object schema". Textually right, semantically inverted for this diff: both members were already writable through z.unknown(), which accepted everything; what the diff does is bound them. That is a limitation of the matcher, not a signal about this PR, and it is in the acceptance notes below rather than repaired here.

Acceptance notes

The two paragraphs below were added by the domain:spec#3 seat after the body's single dev write, on the dev's own hand-over; ⛔ a dev writes a PR body once, at creation.

The migration registry, with four open PRs adding entries to it. Mine, #19090, #19084 and #18319 each add one semantic entry. Identity cannot collide silently: the entry id IS the identity and the filename is a function of it, so a duplicate would be a loud git add/add conflict — the generator says so in as many words, and the four ids are four distinct files. Order is derived (major, id) from the directory listing, with no index file and no positional consumer (migrations/chain.ts keys by MAJOR, MIGRATIONS_BY_MAJOR[m]), so a clean text merge cannot express a wrong meaning — the 18. prefix is the protocol-major bucket, not a sequence number. The gate is pnpm --filter @objectstack/spec check:migration-registry, run at exit 0 (「229 semantic, 195 retired-key, 181 retired-def」 current): it proves the emitted regions equal what the entries directory says, so a merge that dropped one side reds and one that kept both out of order reds too. Adjacency measured over the 141 existing 18.* entries plus the four in flight: 7 / 49 / 61 existing entries lie between mine and #19090 / #19084 / #18319 — no pair is adjacent, and the register's own insertion-only property then predicts a clean, current union whatever the landing order. ⚠️ And registry.ts is deliberately NOT in the merge=os-regen register (classified MIXED, 「a deferral would launder the prose」), so a conflict there is loud and a human's — the silent-drop class does not reach it.

The hand-written docs negative, recorded so it is not reopened. Probe: hand-written content/docs trees (excluding references/ and releases/) authoring a pageSize value this narrowing refuses (0, negative, decimal) → ZERO. Lit control, same instrument: it does find authored pageSize occurrences — content/docs/api/data-api.mdx:42 (?pageSize=5) and content/docs/api/error-catalog.mdx:151 — over 2 hand-written pages and 9 pages including the generated tree, so the zero is a reading rather than a dead grep. Attribution, which is the part that matters: neither control hit is this door's pagination.pageSize — data-api.mdx documents pageSize as an unknown REST query parameter refused in favour of top / $top / limit, and the remaining pages are the metadata response shape, the object page and the metadata-plugin page. Four different pageSize members, none of them this one. ⇒ nothing owed on the hand-written side; the generated content/docs/references/ui/component.mdx already moved in this diff. The attribution step is the prescription of #19093, filed today after a name-based hit produced a false stop-the-line alarm on a sibling PR.

Observations found in passing. ⛔ None is filed as a card by this PR, and none is in its scope.

  • The widening-tells matcher cannot tell a narrowing-inside-a-bag from a widening. A PR that honestly declares Clause-②: no (narrowing) — a legal, precedented declaration (.changeset/17499-groupbyfield-non-padded.md carries exactly it) — and bounds a member inside a previously-z.unknown() bag is blocked at exit 4 by a T1 tell that names the bound as a widening, because the matcher reads the added key text and not the member's prior schema. Reproduced on this diff, above. The honest declaration is the blocked one. The successor: the next accept-set narrowing on this board. Dedupe words: widening-tells T1 narrowing inside z.unknown bag, check-widening-tells false tell narrowing, clause-2 no narrowing blocked exit 4.
  • frozenColumns: z.number().optional() on this same door (component.zod.ts) is unbounded, and the renderer reads it as a leading-column count. ⛔ Not filed and ⛔ not touched: no repro, no measured consumer breakage, and it is not this card's member. Noted, not filed. The successor is any future PR on this door's numeric members.
  • pagination: false / pagination: true on data-table / object-data-table is authored in objectui and those props are not declared in ComponentPropsMap at all, so nothing in this repo judges them. Noted, not filed; that is the sibling repo's declaration surface, not this door's.

Notes for the reviewer

  • ⛔ This PR does not hang, clear or touch needs:contract-review, and writes no label — both carriers are the seat's write. Clause-②: yes is here because triage ruled it; ⛔ this author does not review its own clause-② verdict.
  • packages/spec/api-surface-declarations/ui.txt moved because the declaration text moved. PR 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.
  • No governed surface is in the diff (checked against GOVERNED_SURFACES in scripts/pm/check-governed-merges.mjs): docs/audits/ is not docs/adr/.
  • objectui#9853 is the consumer half's card and objectui#9896 its landed repair; this is the declaration half and was never a prerequisite for it. objectstack#18972 names this same class on the declaration side, and fix(spec): bound scale at the renderer ceiling of 100 (#18972) #19083 landed its scale instance three commits before this branch's merge base.

Generated by Claude Code

…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>
`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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/ui.txt), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/ui.txt) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 07c6f822edd9c4c48a7ce34767de420c6096b9c3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 472a00edf1b03d3d42762623d616dbddae9681a1 — the merge of head 8ecc9b6eda137cde62ddd6f1cd9cc5f9c9e6d690 into base 07c6f822edd9c4c48a7ce34767de420c6096b9c3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

os-elon-musk commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 120/120 CONTRACT_REVIEW_TIER
Head-sha: 8ecc9b6eda137cde62ddd6f1cd9cc5f9c9e6d690

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 claude-fable-5-1 — against the constant at scripts/pm/dispatch-gates.mjs:11899, with the transcript identified as its own by finding its own first command in it. ⚠️ Required method: a subagent's get_session reads the PARENT session's model and attests nothing about the subagent. Reviewed in the reviewer's own worktree at the head above (pnpm install --frozen-lockfile exit 0); origin/main read only through git show / git grep; shared checkout untouched.

① Derived judgments

Accept-set movement — PASS, and measured as a differential rather than argued. A 53-case corpus was run through ComponentPropsMap['object-grid'].safeParse twice — at this head, and with origin/main's component.zod.ts swapped in — and the outputs diffed: 0 newly accepted, 26 newly refused, 24 identical verdict and output. The 26 are non-positive / non-integer page sizes at all three positions, non-array pageSizeOptions, and non-object pagination. ⇒ nothing that parsed before parses differently unless it carries a value the view arm already refuses.

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 pagination.additionalProperties: {}. And the openness pin can fail: ablation B (looseObject → strictObject, one anchor) reds exactly 4 of 19, the three openness cases plus the cross-arm asymmetry case.

Absence and legality — PASS. Absent pagination, pagination: {}, pagination: undefined, legal values and a full document: identical verdict and output at head and main. The diff adds no .default(.

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 HEAD: after each.

No gate weakened — PASS. Zero .skip / .only / xit / .todo / quarantine lines added, no deletions or renames, dropped-refinements.baseline.json untouched, and the strictness ledger's scheduled measure does not move: still-open (strip) stays 126 global and 7 in ui/, strict stays 318; what moves is passthrough 4→5 and site totals 449→450. A declared-open site is a classification, ⛔ not debt.

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 .refine() and an aborting .refine() both project byte-identically to the plain record, so a refinement would have left the published JSON Schema accepting pageSize: 0 while the parser refused it — and would have needed a new row in a shrink-only ledger this dev may not raise. The looseObject projects type: integer, exclusiveMinimum: 0 and keeps additionalProperties open.

The migration entry and the four-way registry — PASS, structurally. shardNameFor makes the filename a function of the id, so a duplicate identity is a loud add/add conflict; order is derived (major, id) and zero positional consumers exist repo-wide (chain.ts, spec-changes.ts, build-upgrade-guide.ts all key by major). Adjacency re-derived over the 141 existing 18.* ids plus the four in flight: 7 / 49 / 61. ⭐ And git merge-tree --write-tree was run against each of #19090 / #19084 / #18319: rc=0 all three, with both ids present in generator order and both shard files present. registry.ts is NOT_DRIVER_MANAGED, so a conflict there is loud, never silent.

The two places the change exceeds the card's literal wording — both judged in scope and adequately declared. pageSizeOptions was bounded too (a zero entry in the selector sets the fetch window to zero rows — the card's own failure by a second door), with a census finding zero authored non-positive entries in either repo against a lit control of the view arm's own refusal fixtures. And pagination's value type moved from unknown to an object: re-measured at the objectui pin, 0 non-object pagination values on an object-grid node against 30 on data-table by the same instrument, and the renderer reads the key for PRESENCE — so an authored pagination: false used to turn paging on. The type move is forced once a member is validated without a refinement.

② Semver level

minor on @objectstack/spec, with the BREAKING banner and an ADR-0087 registered disposition: PASS as declared, and the rule does not point at major. The launch-window clause was verified verbatim in check-changeset-no-major.mjs (「During the launch window we ship breaking changes as minor」, with the GA end condition naming an accept-set narrowing as major 「Until then it is NOT the carrier」), the window is open (no .changeset/pre.json, spec 17.4.0, PROTOCOL_VERSION 17.0.0), and both gates exit 0 locally with Check Changeset green on this head. The disposition derivation holds: all four not-required categories are refused by construction, so registered is the only honest one. ⇒ nothing here for the maintainer floor to rule on.

③ Boundary flags

No security or permission boundary moves. No export is added (GridPageSizeSchema is module-local). content/docs/releases/ is untouched. The consumer half already landed at objectui; this is the declaration half, and the card's own filing seat proposed no direction.

⚠️ Declaration correctness — a finding, and the governance reading is the useful part. The criterion (SKILL.md:477 / :515, references/lanes/spec.md:19-20) gives no here: 0 newly accepted inputs of 53, no new export, and 「收窄仍是语义面,不触条款②」. Triage's mandated yes rests on 「narrows a published accept set = breaking direction」, which is the BREAKING axis (the banner plus ADR-0087), ⛔ not the clause-② axis. ⇒ ruling and criterion conflict on the reason, not on legality, and the reviewer's resolution is the one recorded here: the criterion governs what clause ② IS — spec.md:20 says in as many words that a per-case ruling does not move that line — while triage's yes governs this card's routing, as a legal over-declaration (「按 yes 申报恒不是错误」). Both stand, at different levels. Cost: this review, and a ≥minor floor the launch-window rule had already set.

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 maximum: 9007199254740991 line (an omission, not a wrong number — and this seat's first spelling of this very clause carried a mistyped digit, corrected here); four objectui line numbers have rotted (:4175→:4297, :4359→:4482, :223→:240, :4590→:4714) though every symbol holds; and the changeset's refusal list under-describes the movement (non-numbers inside the bag and unsafe integers also refuse — all non-page-sizes).

Five findings in passing, none blocking: the second axis (pagination: true|false → invalid_type) is declared but unpinned, a one-case follow-up; the safe-integer too_big bound is part of the movement and projected consistently; exotic non-plain objects change output silently and are unreachable from authored JSON/YAML; the Console Pin Gate was SKIPPED because ci.yml's console path filter names no packages/spec path, so no CI job exercised objectui at the pin against this schema — the reviewer measured that side by hand instead; and CI on this head carried Lint & Repo Gates still in_progress at review time, which is ⛔ not a pass.

CI as re-read by this seat when writing this record: 30 success / 4 skipped / 1 in progress (Lint & Repo Gates, started 18:43:16Z), zero non-green, head unchanged.

Implemented-by: claude/issue-19046-grid-pagination-accept-set
Reviewed-by: session_019srGWGCBBCBHqcDoRZpQRh

VERDICT: PASS


Generated by Claude Code

@os-elon-musk
os-elon-musk marked this pull request as ready for review September 18, 2026 19:24
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 9bb059d Sep 18, 2026
51 checks passed
@os-elon-musk
os-elon-musk deleted the claude/issue-19046-grid-pagination-accept-set branch September 18, 2026 22:08
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/m tests tooling

Projects

None yet

2 participants