Skip to content

fix(pm): every --pair run states the inputs it judged from - #18681

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-18456-pair-inputs-stated
Sep 17, 2026
Merged

os-justin merged 1 commit into
mainfrom
claude/issue-18456-pair-inputs-stated

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #18456

Clause-②: no

scripts/pm/ sits outside every workspace package, and the root package is private, so no
files[] can ship this diff — skip-changeset.

The defect

check-clause2-carriers --pair is the landing pre-check every seat runs, and on one pair
(PR #17917 / card #17425) it answered 0 at 02:57Z, 4 at 03:04:09Z and 0 at 03:58:33Z on
2026-09-13 with an identical script blob
. Two explanations were ruled out with controls
(no comment on that thread was ever edited; the board is resolved from the environment, never
from the working directory), so the cause is still UNKNOWN — and the three runs could not be
compared, because not one of them had SAID what it read. The judging half is already
deterministic given a fixed document (--pair-json proves that); what was unpinned is what
document the live path builds
. This states it: every --pair run now closes with a fenced
clause2 input record block on stderr, with the same field roster on every exit, so two runs
that disagree are settled by diffing their two blocks — never by re-running until one side
wins. ⛔ No guess at the cause is dressed as a fix here: no predicate, no state, no row, no
count and no exit code reads one character of the record, and the judging half is untouched.

The record's field roster

Rendered from INPUT_RECORD_RUN_FIELDS and INPUT_RECORD_PAIR_FIELDS and from nowhere else,
so a field cannot silently disappear: a declared field this run could not fill renders with an
explicit token rather than vanishing, and a field the builder fills that the roster does not
declare is NAMED in the block (record.undeclared). Values too long for one line continue on
indented lines under their key.

half fields
run record.version · run.utc · run.mode · run.script.path · run.script.blob · run.script.bytes · run.node · board.repo · board.source · read.plan · read.api · read.token · read.served · read.pair-json · run.requests · pairs.derived
per pair (pair.N.) pr · card · derivation · head-sha · card-comments · card-comment-ids · card-comment-newest · pr-comments · pr-comment-ids · pr-comment-newest · claim.rule · claim.selected · claim.rejected · claim.clause2-line · pr-body.clause2-line

Four of them are worth naming for WHY they are there:

  • run.requests — every read the run issued, in order, with its channel, its exact path
    and its row count. A page asked for with per_page=100 that answers with exactly 100
    rows is the one shape a truncated read and a complete one share, and nothing printed it.
  • claim.rule + claim.selected + claim.rejected — the carrier, the rule that picked it
    and every candidate it did not pick, each with its reason. That separates "the two runs
    selected different comments" from "the two runs applied different rules".
  • claim.selected's body fingerprint (bytes + sha256:) — the field the measured 0/4/0
    actually needs. A misplaced verdict on that thread requires the governing claim to have
    carried no readable declaration while a superseded one did; same ids with a different verdict
    is only possible if the BYTES differed, and the ids were all anybody could see.
  • run.script.blob — git's blob hash of this file, beside the path it ran from. "The blob
    was identical on both sides" was a claim in the incident; it is now a printed fact any seat
    checks with git hash-object. On this PR's head it reads 25d204236aa8296644813109fa77541d6efe1644,
    which is exactly git rev-parse HEAD:scripts/pm/check-clause2-carriers.mjs.

The --json sweep carries the same record under inputs — the same record, ⛔ never a second
format.

The two live blocks the card names

--pair 17917 — the pair from the card. Both it and #18654 have since merged, so --pair
answers exit 2 on each today (the pair cannot be formed from a closed PR). ⭐ That is
precisely the class of exit the old code said the least about, and the block is now complete
on it:

----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:26.210Z
run.mode: --pair 17917
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d204236aa8296644813109fa77541d6efe1644 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=1, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 1 read(s), in the order they were issued
  #1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
pairs.derived: 0 pair(s)
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----

--pair 18654 — the pair this seat landed today, which answered 0 at 12:32Z and is likewise
merged now (exit 2):

----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:27.163Z
run.mode: --pair 18654
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d204236aa8296644813109fa77541d6efe1644 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=1, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 1 read(s), in the order they were issued
  #1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
pairs.derived: 0 pair(s)
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----

⭐ diff of those two blocks is four lines: run.utc and run.mode, twice. Same roster,
same order, same shape — which is the property the card asked for.

A live block on exit 0

--pair 18659 (open at the time of writing) — exit 0, the full pair half:

----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:36.744Z
run.mode: --pair 18659
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d204236aa8296644813109fa77541d6efe1644 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=5, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 5 read(s), in the order they were issued
  #1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
  #2 (i) token /repos/objectstack-ai/objectstack/issues/18443 -> HTTP 200
  #3 (i) token /repos/objectstack-ai/objectstack/issues/18443/comments?per_page=100 -> HTTP 200 (4 row(s))
  #4 (i) token /repos/objectstack-ai/objectstack/pulls/18659/files?per_page=100&page=1 -> HTTP 200 (1 row(s))
  #5 (i) token /repos/objectstack-ai/objectstack/issues/18659/comments?per_page=100 -> HTTP 200 (1 row(s))
pairs.derived: 1 pair(s)
pair.1.pr: 18659
pair.1.card: 18443
pair.1.derivation: `closing-keyword` (via a closing keyword) — body line: Fixes #18443
pair.1.head-sha: 1344eb515e3e68ec0099fb37cd4f101f6ebe65d1
pair.1.card-comments: 4 row(s)
pair.1.card-comment-ids: 5713976124,5714587497,5714873191,5715029659
pair.1.card-comment-newest: 5715029659 at 2026-09-17T13:19:56Z
pair.1.pr-comments: 1 row(s)
pair.1.pr-comment-ids: 5715030051
pair.1.pr-comment-newest: 5715030051 at 2026-09-17T13:19:57Z
pair.1.claim.rule: the GOVERNING claim — the NEWEST comment whose body carries a line beginning `Claim:`/`Claimed:` AND whose `Branch:` line parses at least one protocol-shaped branch (newest by `created_at`; an unreadable stamp or a tie falls back to thread order, later row wins). The pool is every claim comment sharing that `created_at`; when NO claim names a branch at all, every claim comment is the pool. ⛔ Not earliest, ⛔ not a session match, ⛔ not the one whose body mentions the key.
pair.1.claim.selected: 1 comment(s) in the pool
  5714587497 at 2026-09-17T12:46:45Z — 2159 bytes, sha256:795e1df6c9fd
pair.1.claim.rejected: none — every claim comment on this thread is in the pool
pair.1.claim.clause2-line: DECLARED `no` — Clause-②: no
pair.1.pr-body.clause2-line: DECLARED `no` — Clause-②: no ⚠️ stated as an INPUT only — ⛔ no row here judges the PR body; the declaration limb is judged from the card, and `check-changeset-no-major.mjs` is what reads this line.
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----

Pins

Battery #18456: the --pair input record — the same block on every exit, so two runs that
disagree can be diffed
, registered in SELF_TEST_BATTERIES with a floor of 38; 41
cases register. SELF_TEST_BATTERY_FLOOR raised 26 → 27 by exactly the one battery this adds.

What is pinned, in the card's own terms:

  • the record is present and complete on exit 0, on the exit-4 (MISPLACED) shape and on
    a refusal that formed no pair — all three key lists asserted equal;
  • the field roster cannot lose a field: a declared field that was never filled still renders
    (with INPUT_RECORD_UNSET), an empty record still carries every declared key, and a key
    outside the roster is named rather than printed in silence;
  • the selected-claim rule is stated, and it is the one constant claimCarrierSelection
    applies — so the printed rule cannot drift from the applied one;
  • a rejected candidate is named with its reason, and a thread with nothing rejected says so;
  • a --pair-json run names that read path as such and names the document;
  • the body fingerprint moves when only the bytes move while every id field stays identical —
    the measured shape, asserted directly;
  • gitBlobSha1 is pinned against two values git hash-object prints.

⛔ CONTROLS in the same battery: the block carries no verdict, no exit code and no finding row;
building it changes no reading; and the selection the block prints IS the pool cardDeclaration
judged (ONE derivation — cardDeclaration now calls claimCarrierSelection instead of deriving
the pool inline, so the record and the verdict cannot describe two different comments).

--self-test on this head: 786 cases pass, exit 0 (745 before; +41).

Ablation

From the committed tree, blob 25d204236aa8296644813109fa77541d6efe1644 (= this PR's head
blob), the pair half of the record removed on disk, mutation proved before the run, restore by
blob hash under a trap:

HEAD blob                25d204236aa8296644813109fa77541d6efe1644
before: removed-text count=1 (want 1); injected count=0 (want 0)
after : removed-text count=0 (want 0); injected count=1 (want 1)
mutated blob             e88355f70e8648f1e3d30147f0c82b7c3c157609
VERDICT ablation-mutated self-test exit=1       ← 14 cases red
  ✗ every declared PAIR field is present once per derived pair, prefixed by its index
  ✗ the SELECTION RULE is printed, not merely applied — two runs must be comparable on the rule too
  ✗ …and it is the one constant, so the printed rule cannot drift from the applied one
  ✗ the SELECTED carrier is named by id and by date
  ✗ ⭐ …with a BODY FINGERPRINT: the one field that tells "same ids, different bytes" apart
  ✗ ⭐ …and it MOVES when only the bytes move: same ids, same count, same newest, different verdict
  ✗ every REJECTED candidate is named, with the reason it is not the carrier
  ✗ …and a thread whose claims are all in the pool says THAT, rather than going quiet
  ✗ a claim that parses ZERO branches leaves NO carrier, and the block names that claim
  ✗ an UNREAD thread reads UNREAD, ⛔ never 0 rows
  ✗ the line READ from the carrier is stated — declared, near miss or nothing
  ✗ the PAIRING quotes the body line it was derived from
  ✗ …and the branch-name fallback names the head ref instead of quoting a line that does not exist
  ✗ the PR-BODY line is read and stated — ⛔ and stated as an INPUT, never as a limb
restored blob            25d204236aa8296644813109fa77541d6efe1644   (HEAD 25d204236aa8296644813109fa77541d6efe1644)
git diff HEAD --name-only: []
VERDICT ablation-restored self-test exit=0

Direction predicted before the run and observed: turns red. The module is run directly from
source by node scripts/pm/… — no build and no dist/ between the edit and the run, so the
on-disk proof is the whole preflight.

⚠️ A named gap, not a hidden one: the battery drives the builder and the renderer, and it
cannot see main's emission. An ablation that deleted the two lines in main's finally
would come back green. What covers emission is the three live blocks quoted above, taken on this
head across three different exits.

Candidate cause, unproven — ⛔ not fixed here

Two readings taken while wiring the record. Neither is acted on in this PR.

1. On the blob all three 2026-09-13 runs ran, exit 4 was the DETERMINISTIC answer for that
pair — so what is unexplained is the two 0s, not the 4.

  • The file's last change before those runs was a5ed18ced (2026-09-12T06:05:25Z, "a key-INITIAL
    clause-② line that QUOTES the spelling is not a declaration"); its next change was
    4e3a496ba at 2026-09-13T17:19:18Z, after all three runs. a5ed18ced's blob is
    aecbb2d86683eb908468fdacaac2ff53753f06ef — the same blob PR fix(pm): the clause-② correction remedy names THIS card's claim comment, never a specimen from another card #18448's body independently cites
    as "the exact blob the 2026-09-13 readings were taken from".
  • The governing claim on card ObjectPermissionSchema's retired allowRestore/allowPurge: only literal false parses (not a truthy/falsy split), and no post-parse guard can ever see either key #17425 at that moment was comment 5650083758
    (2026-09-13T01:57:23Z). Its line 3 opens Clause-②: no — and then quotes the spelling again
    inside the same line. Run first-hand against that historical blob's own
    readClause2Line
    : {"kind":"near-miss","reason":"describing"}, and cardDeclaration on a
    one-claim thread reads missing — ⛔ not a declaration. Today's copy reads it identically.
  • A --pair-json document assembled from the REAL thread as it stood at 03:04:09Z (its 9
    comments, both carriers' real label event streams) answers exit 4, MISPLACED on this PR's
    head, quoting the superseded Clause-②: yes and naming 5650083758 as the correction target —
    which is what the 03:04Z reviewer and the 02:53Z dev round both reported.
  • ⇒ The 4 is reproducible and mechanically explained. The 0s are not. ⭐ Exactly the difference
    the record's claim.selected fingerprint and claim.clause2-line would have shown, had the
    0-runs printed one.
  • ⚠️ Limits of this reading: the historical module was exercised for readClause2Line (self
    contained) and cardDeclaration (which imports today's sibling modules); the commit ordering
    is read from a shallow checkout, corroborated by fix(pm): the clause-② correction remedy names THIS card's claim comment, never a specimen from another card #18448's independent citation of the same blob.

2. The comment read — the one the declaration limb depends on — is the only read here with no
page discipline.
readCardComments issues ONE request, /issues/N/comments?per_page=100, with
no page= ladder and no short-read check. readCarrierEvents and readPullFiles both page to
exhaustion and answer null (UNJUDGED, never clean) when their cap is hit, for the reason their
own docblocks state. A card thread past 100 comments therefore loses its tail silently, and the
claim pool is built from whatever came back. Not the cause on #17425 (7 comments at 02:53Z, 16
today), but it is a live fail-open in this reading. The record makes it visible for the first
time: request #3 prints its row count, so a (100 row(s)) on a per_page=100 request is now
readable. ⛔ Not fixed here; the seat files or re-scopes.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
from the worktree with no hand-fed path list; re-derived after rebasing onto current main
(the derivation was STALE-TREE by 4 commits) — identical command list. All 34 run at head
f5773ce08, exit codes captured redirect-then-$?:

0 :: node scripts/check-adr-0087-registration.mjs --base origin/main
0 :: node scripts/check-adr-0087-registration.mjs --self-test
0 :: node scripts/check-changeset-no-major.mjs --base origin/main
0 :: node scripts/check-changeset-no-major.mjs --self-test
0 :: node scripts/check-ci-filter-parity.mjs
0 :: node scripts/check-closing-keyword-parity.mjs
0 :: node scripts/check-closing-keyword-parity.mjs --self-test
0 :: node scripts/check-comment-mask-corpus.mjs
0 :: node scripts/check-declaration-mirrors.mjs
0 :: node scripts/check-declaration-mirrors.mjs --self-test
0 :: node scripts/check-scripts-symbol-anchors.mjs
0 :: node scripts/check-scripts-symbol-anchors.mjs --self-test
0 :: node scripts/check-self-test-wired.mjs
0 :: node scripts/check-self-test-wired.mjs --self-test
0 :: node scripts/check-self-test-workflow-commands.mjs
0 :: node scripts/check-self-test-workflow-commands.mjs --self-test
0 :: node scripts/check-whole-set-label-write.mjs
0 :: node scripts/check-whole-set-label-write.mjs --self-test
0 :: node scripts/pm/bare-root-worklist.mjs --self-test
0 :: pnpm check:agent-test-spelling
0 :: pnpm check:bash32-floor
0 :: pnpm check:changeset-gate-self-tests
0 :: pnpm check:cli-command-ids
0 :: pnpm check:cross-package-test-inputs
0 :: pnpm check:driver-memory-census
0 :: pnpm check:entry-guard
0 :: pnpm check:nul-bytes
0 :: pnpm check:parse-guard
0 :: pnpm check:pm-clause2-carriers
0 :: pnpm check:pnpm-filter-targets
0 :: pnpm check:ratchet-remedy-authority
0 :: pnpm check:refd-timer-probe
0 :: pnpm check:watch-hint-literal
0 :: pnpm check:pm-dispatch-gates

Reconciled: dispatch-gates --ran ⇒ 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN.

Repo-wide pnpm lint (eslint . --no-inline-config) at f5773ce08: exit 0.
grep -naP for control bytes over the changed file: no hits.

⛔ Outside these 34, as the derivation itself prints: 53 artifact-roster families, 11
wide-population families, 7 pending-changeset families, 1 path-scheduled CI job and the
always-runs tail. Their absence here is not a clearance.

Acceptance notes

Out of scope, noted and ⛔ not filed:

  • The read-path report and the input record now also print on the --pair-json usage
    refusals
    (a missing file, a non-JSON document, a board conflict), because everything past the
    board resolution moved inside one try/finally. One extra stderr line on those paths, in the
    direction the file's own header argues for. Carrier: whoever next edits main.
  • main's --pair value is parsed in two places now (once for run.mode, once for the pair
    itself). Both read the same argv through flagIndex; a reader may prefer one. Carrier: whoever
    next edits main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu


Generated by Claude Code

`check-clause2-carriers --pair` answered 0 / 4 / 0 on one pair with an
identical script blob, and nothing either run printed could settle which
of them had read what. The judging half is already deterministic given a
fixed document (`--pair-json` proves it); what was unpinned is what
document the LIVE path builds.

Every run past the board resolution now closes with a fenced
`clause2 input record` block on stderr — the board and which of the three
sources answered, the read path and every request it issued with its row
count, the pair and the evidence it was derived from, the comments read
by count/ids/newest, the claim comment SELECTED as the carrier with the
rule that selected it and every candidate it rejected with a reason, each
pooled claim's body fingerprint, the PR-body line, and this file's own
blob hash and path. The field roster is the same on exit 0, exit 4 and
every refusal, so two runs that disagree are diffed rather than re-run.

The `--json` sweep carries the same record under `inputs`. No verdict, row,
count or exit code reads any of it.

Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu
Co-authored-by: Claude <noreply@anthropic.com>
@os-justin os-justin added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 17, 2026 — with Claude
This was referenced Sep 17, 2026
@os-justin
os-justin marked this pull request as ready for review September 17, 2026 14:41
@os-justin
os-justin added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 1bc22b3 Sep 17, 2026
37 checks passed
@os-justin
os-justin deleted the claude/issue-18456-pair-inputs-stated branch September 17, 2026 15:03
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… list with a counterfactual pin (objectstack-ai#18700)

Fixes objectstack-ai#18560

**Clause-②: no**

`scripts/pm/check-widening-tells.mjs` is the TELL half of the clause-②
gate (the DECLARATION half, `check-clause2-carriers.mjs`, is a different
file and PR objectstack-ai#18681's subject). Its T1 reader decides a line declares a
schema member by what the property's VALUE opens with, and that
vocabulary was five alternatives inside one 130-character regex literal.
A form missing from it is not a line judged leniently — it is a line
that is not a key line at all: `memberTellKind` answers `null`, the row
neither fires nor spends the objectstack-ai#16943 replacement budget nor earns it on
the removed side, and nothing in the output says so. The silence is
indistinguishable from a correct `no`.

## The counterfactual, before and after

Re-derived here rather than inherited from the card's reading
(objectui#9647 comment 5707064702, an at-tier reviewer's measurement).
Run as `PM_SWEEP_REPO=objectstack-ai/objectui node
scripts/pm/check-widening-tells.mjs --declaration no --diff …`, the
BEFORE leg against the file as it stands at `6dfa3ea77` (this branch's
merge base), the AFTER leg at `7f1a81419`:

| diff | declaration | BEFORE (6dfa3ea) | AFTER (7f1a814) |
|---|---|---|---|
| objectui#9647, unmodified | `no` | exit 0, 1 file judged, **0 tells**
| exit 0, 1 file judged, 0 tells |
| objectui#9647 plus one key added through `stripImportedDefaults(` |
`no` | **exit 0, 0 tells** | **exit 4, T1 at
`packages/types/src/zod/data-display.zod.ts:584`** |
| the same widened diff | `yes` | exit 0 | exit 0 (a `yes` is never
blocked) |

Row 2 is the red this PR turns. Row 1 does NOT change verdict, and that
is the correction the re-derivation forced.

## What the re-derivation corrects

The two helpers the card names are objectui's REFUSAL family
(`packages/types/src/zod/tombstone.zod.ts`), read off the source:

- `retirementTombstone(guidance)` returns `z.never({ error: guidance
}).optional().describe(guidance)` — the same primitive as this repo's
`retiredKey()`;
- `handlerKeyRefusal(key, disposition, label)` returns a `z.custom`
predicate typed `never` that returns `false`, and its own docblock
records that "The predicate refuses EVERYTHING, a live function
included".

A key declared through either is a key an author may NO LONGER write.
Making those two fire a tell would re-mint, on 290 objectui key lines,
the exact false positive objectstack-ai#17955 removed on 255 objectstack ones — and a
false tell does not cost a word in a comment, it costs the false `yes`
this file's own header refuses to ask an author for.

The form that DOES carry a widening, and that no seat had named, is
`stripImportedDefaults()`
(`packages/types/src/zod/imported-defaults.ts`), whose docblock states
its contract as "the same TypeScript type, the same keys, the same
checks, the same registry metadata and the same accept set". It returns
a LIVE schema and is spelled at 45 key positions on the judged objectui
surface.

## The named list

`SCHEMA_PROPERTY_FORMS` — exported, frozen, and the constant
`SCHEMA_PROPERTY` is BUILT from it. Two fields carry two questions:
`pattern` (what makes the line a KEY LINE) and `writable` (whether the
key it declares is one an author may write). Counts are key-POSITION
counts, each with the tree it was taken against.

| form | writable | measured |
|---|---|---|
| `z.` | yes | 7,784 at objectstack `6dfa3ea77` · 1,482 at objectui
`15f01223d` |
| `lazySchema(` | yes | 0 at objectstack `6dfa3ea77` (live at
DECLARATION positions) |
| `strictObject(` | yes | 47 at objectstack `6dfa3ea77` |
| `*Schema` | yes | 995 at objectstack `6dfa3ea77` · 56 at objectui
`15f01223d` |
| `stripImportedDefaults(` | yes | **45 at objectui `15f01223d`**
(added) |
| `retiredKey(` | no | 255 at objectstack `6dfa3ea77` |
| `retirementTombstone(` | no | **187 at objectui `15f01223d`** (added)
|
| `handlerKeyRefusal(` | no | **90 at objectui `15f01223d`** (added) |
| `aliasKeyRefusal(` | no | **13 at objectui `15f01223d`** (added) |

The `writable: false` arm is objectstack-ai#17955's decline generalised from one
helper name to the family, on the SAME positive, line-local evidence:
the value must BE the call and nothing after it.
`declaresRetiredKeyTombstone` is renamed `declaresUnwritableKey`
accordingly; it is not imported by any other file.

**The `no` criterion is not loosened anywhere, and the direction is
provable rather than argued.** An unrecognised line reports NOTHING, so
no row that fires today can stop firing when the list grows. A self-test
case keeps the literal this replaced as the reference and asserts it:
every legacy verdict is byte-identical, and the only cells that move are
the four added forms moving from "not a key line" to "a key line" — one
direction, zero losses.

## The pins

New battery, registered on the roster and pinned: `'objectstack-ai#18560 — the
declaring vocabulary is a NAMED list, every form pinned by a
counterfactual fixture': 30` (39 cases run against a floor of 30). Its
unit is the FORM, not the assertion:

- a frozen fixture roster is asserted EQUAL to the form set, in both
directions and naming the offenders — a form added to the list without a
fixture reds, a form silently dropped from the list reds. The fixtures
are deliberately NOT generated from the list, which would make every
future form pass by construction;
- every form is asserted RECOGNISED (`memberTellKind` answers T1) —
writable or not, because an unrecognised line is invisible to both sides
of the budget, which is the defect itself;
- every form's fixture is then driven through `tellsInFile` and asserted
against its OWN register: a `writable` form must FIRE with its
file:line, an unwritable one must be recognised and DECLINE;
- every unwritable form carries the chained-arm control that FIRES, so
the decline is bound to the evidence the line carries and never to the
helper's name;
- the objectui#9647 shape is carried as a case in all three readings,
plus the `stripImportedDefaults(` widening with its refusal, its
file:line, the `yes` control and the default-board control.

`check:entry-guard` is the reason the vocabulary's structural guard is a
const initializer's `map` step rather than a top-level `for`: this file
exports bindings the sibling gate imports, so a load-time throw would
run inside the importer.

## The census — report-only, no re-grade, no state write

Has the silence already been relied on? **Zero confirmed `Clause-②: no`
landings through these forms, over 11 of 18 rows read.**

- **Horizon.** objectui's full history (`git rev-parse
--is-shallow-repository` = false, 10,282 commits, initial commit
2026-01-13) up to the checkout tip `15f01223d` (2026-09-16). PR
objectui#9647 itself is OUTSIDE it: `git merge-base --is-ancestor
604476d HEAD` exits 1, with the initial commit as the control leg at
exit 0 on the same non-shallow checkout. The judgeable part of that
window starts 2026-09-10, when objectstack-ai#17278 first let this CLI be told which
board it judges.
- **Population.** 46 commits add a key through one of the four added
forms on `packages/types/src/zod/**`; 18 land inside the judgeable
window.
- **Coverage — read (11 of 18):** 10 carry `Clause-②: yes` in the PR
body (objectui#8884, objectstack-ai#8895, objectstack-ai#8930, objectstack-ai#8967, objectstack-ai#9051, objectstack-ai#9338, objectstack-ai#9495, objectstack-ai#9539,
objectstack-ai#9541, objectstack-ai#9565); 1 (objectui#9443) carries a "Clause-② carriers" section
attaching `needs:contract-review` with no `yes`/`no` token.
- **Coverage — NOT ATTEMPTED (7 of 18):** objectui#8984, objectstack-ai#9254, objectstack-ai#9261,
objectstack-ai#9343, objectstack-ai#9544, objectstack-ai#9589, objectstack-ai#9621 carry no declaration in the PR body; the
remaining carrier is each card's claim comment, which was not read.
**This is NOT a zero** — it is seven rows unread and named.
- The commit-local changeset is a weak carrier in objectui: only 2
commits in the whole history carry a `Clause-②` line in a changeset,
which is why the census reads PR bodies rather than the tree.

## The ablation

From the committed fix, `HEAD` blob
`1b741ee6c22db6523475b698d392a8ecd41686d6`, under a `trap '…' EXIT INT
TERM` restoring `git checkout HEAD --
scripts/pm/check-widening-tells.mjs` at an absolute path. No build or
`dist/` is involved — the gate runs from source, so there is no rebuild
leg to prove.

- **mutation:** the `retirementTombstone(` and `handlerKeyRefusal(` rows
deleted from `SCHEMA_PROPERTY_FORMS`;
- **mutation proved on disk, not from an exit code:** anchor counts 1 →
0 for each form, file 257,365 → 256,697 bytes, blob
`9fb65edf6f68b1501a0aec29d0ddb4c95bcf44a2` which is not the HEAD blob;
- **predicted direction:** RED. **Observed:** RED — `--self-test` exits
1, "6 of 377 case(s) failed" plus the verdict-handshake report. The
failures name the mechanism: the orphaned-fixture case prints both
dropped forms, both RECOGNITION cases fail, both objectui#9647 cases
fail, and the one-direction case fails;
- **restore verified by hash, not by a return code:** `git hash-object`
back to `1b741ee6c22db6523475b698d392a8ecd41686d6`, `git diff HEAD`
empty, `git status --porcelain` clean, and the suite back to 381 cases
pass.

## Self-test

`pnpm check:pm-widening-tells` — **381 cases pass** (342 before this PR;
+39). Exit 0. The two gates that IMPORT this module were run too:
`check:pm-clause2-carriers` exit 0, `check:pm-prior-rulings` exit 0.

## Derived gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` from the worktree, no hand-fed path list —
29 commands, each run with `$?` captured BEFORE any pipe. Reconciled
with `--ran`: "29 derived famil(ies) accounted for — 29 run, 0
NOT-MEASURED (a DERIVED zero — all 29 recorded an exit code and none of
them is 3)".

```
exit 0  node scripts/check-ci-filter-parity.mjs
exit 0  node scripts/check-closing-keyword-parity.mjs
exit 0  node scripts/check-closing-keyword-parity.mjs --self-test
exit 0  node scripts/check-comment-mask-corpus.mjs
exit 0  node scripts/check-declaration-mirrors.mjs
exit 0  node scripts/check-declaration-mirrors.mjs --self-test
exit 0  node scripts/check-scripts-symbol-anchors.mjs
exit 0  node scripts/check-scripts-symbol-anchors.mjs --self-test
exit 0  node scripts/check-self-test-wired.mjs
exit 0  node scripts/check-self-test-wired.mjs --self-test
exit 0  node scripts/check-self-test-workflow-commands.mjs
exit 0  node scripts/check-self-test-workflow-commands.mjs --self-test
exit 0  node scripts/check-whole-set-label-write.mjs
exit 0  node scripts/check-whole-set-label-write.mjs --self-test
exit 0  node scripts/pm/bare-root-worklist.mjs --self-test
exit 0  pnpm check:agent-test-spelling
exit 0  pnpm check:bash32-floor
exit 0  pnpm check:cli-command-ids
exit 0  pnpm check:cross-package-test-inputs
exit 0  pnpm check:driver-memory-census
exit 0  pnpm check:entry-guard
exit 0  pnpm check:nul-bytes
exit 0  pnpm check:parse-guard
exit 0  pnpm check:pm-dispatch-gates
exit 0  pnpm check:pm-widening-tells
exit 0  pnpm check:pnpm-filter-targets
exit 0  pnpm check:ratchet-remedy-authority
exit 0  pnpm check:refd-timer-probe
exit 0  pnpm check:watch-hint-literal
```

Repo-wide `pnpm lint` (`eslint . --no-inline-config`) at `7f1a81419`:
**exit 0**, no findings. Control-byte scan over the edited file: no
hits.

`skip-changeset`: `scripts/pm/**` is not published by any package's
`files[]`, and this diff touches nothing else.

## One boundary this does NOT close, recorded rather than left to be
found

A FILE-LOCAL declaring factory. Both trees mint them —
`placeholderFree(` (23 key lines), `strictIdent(` (12), `emptyProps(`
(9) at objectstack `6dfa3ea77`; `chatbotRequestBodyArm(` (2),
`retiredDeclarativeKanbanKey(` (1) at objectui `15f01223d` — and a list
of shared, exported helpers cannot name a factory private to one file. A
name-shaped heuristic is refused in the header, with the overturn
condition written down. Filed as its own finding in the report on the
card, not fixed here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ast 100 is UNJUDGED rather than a truncated claim pool (objectstack-ai#18799)

Fixes objectstack-ai#18683

Clause-②: no

## The defect

`scripts/pm/check-clause2-carriers.mjs` read a card's comment thread
with ONE request — `/issues/{n}/comments?per_page=100`, no `page=`
ladder, no short-read check — while the two sibling list reads in the
same file paged to a declared cap and answered `null` (UNJUDGED, never
clean) when they hit it. One file, two OPPOSITE defaults on "I did not
read everything", and the fail-OPEN one was the read that arbitrates
OWNERSHIP: the governing-claim pool, its membership, and the `Clause-②`
declaration read out of it all come from those rows. A thread past 100
comments handed the pool its first page and nothing said the tail had
been dropped, so a claim written past row 100 was not superseded — it
was never a candidate — and a superseded carrier governed in its place.

## The before-reading, on a 101-row fixture

Driven end to end through the real CLI against a stubbed board
(`--pair`, no network), on `main` `d9ba33df4c` (script blob
`d753e2a8cf06d8f72c436e0a1917b2fecb8be813`). Two fixtures, both 101
rows, both differing from a complete thread only past the page boundary.

| fixture | BEFORE (`main`) | AFTER (this PR) |
|---|---|---|
| 100 claim-free rows, the 101st the only `Claim:` | `card-comments: 100
row(s)` · `claim.selected: none — no comment on this thread carries a
line beginning \`Claim:\`` · **exit 4, row C2 `absent`** |
`card-comments: 101 row(s)` · the 101st claim is the pool ·
`claim.clause2-line: DECLARED \`no\`` · **exit 0** |
| row 1 an older `Claim:` declaring `yes`, the 101st a newer one
declaring `no` | `claim.clause2-line: DECLARED \`yes\`` from the
SUPERSEDED carrier, which is not even listed as rejected · **exit 4, row
C3** | the newer claim governs, the older is listed REJECTED/SUPERSEDED
· `DECLARED \`no\`` · **exit 0** |

The second row is the fail-OPEN direction stated as a measurement: one
thread, two readings, and they disagree on the declaration itself.

## The ladder, and the cap

All three list reads now go through one `pagedListRead` helper — it
pages to a declared cap, stops on the FIRST short page (no wasted
request), and on the cap files the one shared `pageCapNote` sentence and
answers `null`. `readCarrierEvents` (`EVENT_PAGE_CAP` 10) and
`readPullFiles` (`FILE_PAGE_CAP` 3) keep their caps to the number; what
they gain is that the third read can no longer hold a different default.

`COMMENT_PAGE_CAP` is **10** pages = 1,000 comments. Sized on this
board, read 2026-09-17 off the open-issue list rows (550 rows listed,
cross-checked against `open_issues_count` = 550):

- longest open thread of any kind: seat post objectstack-ai#6015 at **895** comments —
nine pages;
- next four: objectstack-ai#12708 at 365, objectstack-ai#6023 at 241, objectstack-ai#6017 at 206, objectstack-ai#6024 at 187;
seat post objectstack-ai#7623 at 71;
- longest thread carrying a queue label: objectstack-ai#13799 at **117** (`pm:queue`,
p2, unassigned);
- longest card in the clause-② population (28 pairs the sweep derived
that day): objectstack-ai#17534 at **14**.

So ten pages clears the whole board today with a page to spare, and it
is the same ten `EVENT_PAGE_CAP` uses — a reader comparing two caps in
one file should have to remember one number.

## The input record

The diagnosis key stays `comments`, so every sentence already keyed to
it still finds its diagnosis. Two declared fields are added to
`INPUT_RECORD_PAIR_FIELDS`, one per thread this file reads:

```
pair.1.card-comments: 101 row(s)
pair.1.card-comment-pages: 2 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE
pair.1.pr-comment-pages: 1 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE
```

and, when the cap is what stopped the read:

```
pair.1.card-comment-pages: CAPPED — 10 of 10 page(s) of 100 comments each were requested and EVERY ONE came back full, so the tail is past the cap and the thread is UNREAD (UNJUDGED) — ⛔ never a truncated pool, ⛔ never a clean reading
```

A thread of exactly 100 rows and a thread whose tail was dropped are the
same `100 row(s)` in every other line the block prints; they differ
here, because the complete one stopped on a short page and the truncated
one did not stop at all. The request ledger PR objectstack-ai#18681 added shows the
same ladder from the other side — request objectstack-ai#3 is now
`…/comments?per_page=100&page=1` and objectstack-ai#4 is `&page=2`.

## The pins

A new `--self-test` battery, `objectstack-ai#18683: the card-comment read pages to a
cap — past 100 is UNJUDGED, ⛔ never a truncated pool`, 27 cases,
declared in `SELF_TEST_BATTERIES` with the roster floor raised 29 → 30.
It drives the ladder with an offline page server that reproduces
GitHub's own semantics and counts the requests; ⛔ no network. What it
holds: the 101st claim ENTERS the pool and GOVERNS, and its line is what
the limb reads; the same thread cut at 100 reads `absent` (the CONTROL —
the reading the un-paged read produced); the newer claim past the
boundary supersedes the older one inside it, and cut at 100 the
superseded carrier's `yes` is what the limb reads; a capped read is
`null`, which is neither `missing` nor `absent` nor a carrier but
`unreadable`; the ladder stops on the first short page (2 requests for
101 rows, 1 for a short thread, 2 for exactly 100 — a full page is
indistinguishable from a finished one); a page that came back unread
ends the ladder and the record says the cap was NOT what stopped it; the
input record declares and prints both ladder fields; the sibling caps
are untouched; and all three reads render ONE cap sentence.

## The census, and the triage's upgrade probe

Report-only, no state write. Over the 550 open rows (521 issues, 29 PRs)
read on 2026-09-17:

- open `pm:queue` / `pm:dispatched` cards: **274**, of which **1**
exceeds 100 comments — objectstack-ai#13799 at 117;
- all open issues over 100 comments: **8** — objectstack-ai#6015 (895), objectstack-ai#12708 (365),
objectstack-ai#6023 (241), objectstack-ai#6017 (206), objectstack-ai#6024 (187), objectstack-ai#6021 (145), objectstack-ai#6367 (127), objectstack-ai#13799
(117). Seven are `pm:seat` posts;
- open PRs over 100 comments: **0**; the longest is objectstack-ai#18638 at 10.

**The upgrade probe's result: the condition is NOT met today.** The
clause-② population is what a `--pair`/sweep derivation actually pairs,
not what carries a queue label: the sweep derived **28 pairs from 29
open PRs**, and the longest card thread among them is **14** rows
(objectstack-ai#17534). The two open PRs that mention a 100+-comment card in prose —
objectstack-ai#18786 (objectstack-ai#6015, objectstack-ai#7623) and objectstack-ai#18765 (objectstack-ai#6024) — deliver objectstack-ai#18693 and objectstack-ai#18652
respectively, both under 10 comments; driven live before and after, both
answer exit 0 with an identical pair reading. So no recorded `--pair`
verdict on this board today was taken on a truncated pool, and the
triage's p1 condition (「找到任一进入条款②认领池、评论数 > 100 的卡并驱动一次」) has no live
instance to drive. The exposure is one PR away rather than realised:
objectstack-ai#13799 is `pm:queue` at 117 and enters the population the moment a PR
delivers it.

The cost is unchanged by the ladder, measured on the same board: **64
reads for 28 pairs, before and after**, because every live thread fits
one page and the ladder stops on a short page. The live `--pair 18765`
input records differ in exactly three lines — the two request paths
gaining `&page=1`, and the two new ladder fields.

⚠️ One thing the two full sweeps do NOT compare: the sweep's finding
COUNT moved 4 → 3 between them, and that is the board, not this diff.
`needs:contract-review` was hung on PR objectstack-ai#18792 at `2026-09-17T21:04:46Z`,
between the two runs, closing the C1 split on objectstack-ai#18792 / objectstack-ai#17541 on its
own. The controlled A/B is the `--pair 18765` diff above.

## The ablation

Two legs, each from the COMMITTED fix, each proving the mutation reached
disk by blob hash and occurrence count before reading any result, each
restored under a `trap` with `git checkout HEAD --` and verified by hash
and an empty `git diff HEAD`. HEAD blob
`ccd5ad7c9a00fe703d644be261a24f1ed847915a`.

| leg | mutation | blob after | self-test |
|---|---|---|---|
| A — the ENTRY side | `COMMENT_PAGE_CAP` 10 → 1 |
`1a0a952d07748101937420006b9475042d93a5cb` | **exit 1, 11 of 865 red** —
the 101st-claim pins, the superseding pins, the request-count pins, the
input-record pin |
| B — the UNJUDGED side | the cap branch returns the pages that DID
arrive (the pre-fix fail-OPEN default) |
`c176dfe7e54e7de6bc45737487841a346f509b79` | **exit 1, 3 of 865 red** —
a capped read is no longer `null`, no longer `unreadable`, and files no
cap sentence |

Every red in both legs belongs to the new battery; nothing pre-existing
went red in either. A third, unplanned reading came for free: leg B's
first attempt was a `perl -0pi` substitution whose anchor contained a
`/`, so the edit silently did nothing — the on-disk proof refused it
with `ABLATION VOID: the edit did not reach disk` instead of reporting a
green as a measurement.

## Self-test

```
✓ check-clause2-carriers self-test: 865 cases pass
```

838 before, 865 after — the 27 the new battery registers, which is what
its floor pins.

## Derived gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, no hand-fed path list, re-derived after
each `origin/main` merge (identical list both times). All 34 run at head
`993cb89e18`, each exit code captured by redirect-then-`$?`:

```
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-changeset-no-major.mjs --self-test :: exit 0
node scripts/check-ci-filter-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test :: exit 0
node scripts/check-scripts-symbol-anchors.mjs :: exit 0
node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0
node scripts/check-self-test-wired.mjs :: exit 0
node scripts/check-self-test-wired.mjs --self-test :: exit 0
node scripts/check-self-test-workflow-commands.mjs :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0
node scripts/check-whole-set-label-write.mjs :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:bash32-floor :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:entry-guard :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:parse-guard :: exit 0
pnpm check:pm-clause2-carriers :: exit 0
pnpm check:pm-dispatch-gates :: exit 0
pnpm check:pnpm-filter-targets :: exit 0
pnpm check:ratchet-remedy-authority :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm lint :: exit 0
```

`--ran` reconciles 34 derived / 34 run / 0 UNRUN. `pnpm
check:pm-dispatch-gates` was run detached to a file — 1,788 cases,
748.6s on this box — and waited on in the foreground rather than under a
timeout, so it is a measurement and not a SIGTERM.

## Out of scope, deliberately

objectstack-ai#18764 (a decorated `**Claim:**` never enters the pool — the ENTRY side)
was read and NOT folded in: this card is WHICH rows reach the reader,
not what the reader does with them, and the two repairs touch different
lines. `claimRetractions` (PR objectstack-ai#18770, the EXIT side) was read for the
words it uses and not touched. The header's request-budget paragraph is
amended in the same commit, because it stated "2 reads per card" as a
fact and the thread is now a ladder — a cost statement that stopped
being true is the shape this file exists against.

`skip-changeset`: `scripts/pm/**` ships in no package's `files[]`, so
nothing published moves.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_

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

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants