Skip to content

feat(lint): name the retired allowRestore/allowPurge residue at the authoring door - #17917

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-17425-retired-permission-residue-lint
Sep 13, 2026
Merged

os-bill merged 3 commits into
mainfrom
claude/issue-17425-retired-permission-residue-lint

Conversation

@os-bill

@os-bill os-bill commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #17425

Clause-②: yes — flipped by the domain:spec seat, 2026-09-13T02:5xZ. The round declared no and correctly left the final value to the seat, reporting both limbs separately. The deciding limb is the mechanical one: references/contract-review.md:13 — 「新导出符号或已发布载荷上的新键恒 yes」. This diff adds three new exported symbols to @objectstack/lint's published barrel (validateRetiredPermissionResidue, the RetiredPermissionResidueFinding type, and PERMISSION_RETIRED_LIFECYCLE_RESIDUE), verified by the seat from the diff against merge base 5741ff10c30, and measured by the round in the built tarball (6 published dist files each). ⇒ yes, unconditionally. The round's own reasoning — no schema touched, no accept set moved, packages/spec not in the diff — is the OTHER limb and is accurate; it does not reach this one. needs:contract-review hung on both carriers (PR and card) in the same stroke.

This is the second, lint half of the card. The parse half landed as #17485 and is not re-opened here: #12840's retired-default residue tolerance stays exactly as ruled, packages/spec/src/security/permission.zod.ts and shared/retired-key.ts are untouched, and nothing about what parses changes. This implements the director seat's ruling D — the missing signal is delivered where the authored path and the built path ARE distinguishable, which is before the parse.

The gap, in the contract's own words

acceptRetiredDefaultResidue states why its accept is silent, and in the same sentence names the channels that stay loud for authored sources:

the strip is deliberately SILENT — real artifacts carry the residue once per permission entry, and a per-occurrence notice would be a 75-line storm that teaches operators to skim; the loud channels for authored sources (tsc never, os migrate meta, the D2 conversion) are unchanged.

Read that list against a non-TypeScript author and it is one entry short.

  • tsc never is a TypeScript channel. An author using definePermissionSet cannot write the key at all.
  • os migrate meta and the ADR-0087 D2 conversion are the same channel twice — and that conversion, permission-allow-restore-purge-removed, is declared retiredFromLoadPath: true, so it never runs while a stack loads. Measured: normalizeStackInput over a raw stack carrying allowRestore: false emits 0 conversion notices and hands the key straight through.

So an author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent, and it is exactly the complaint the card was filed for.

Population measurement — taken FIRST, because it gates the severity

The ruling made this the ordering, so it is reported before the choice it gates.

Authored stack sources in this tree carrying the retired keys: ZERO.

The census classified every in-tree carrier structurally rather than by token count (occurrences via grep -o, never grep -c line counts):

class occurrences largest carrier
built artifact / fixture 150 packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json (75 + 75)
tests 123 packages/spec/src/security/permission.test.ts (44)
spec / runtime machinery that NAMES the keys 88 packages/spec/src/security/permission.zod.ts (26)
docs and changelog prose 99 packages/spec/CHANGELOG.md (18)
changesets 4 —
authored stack source 0 none

LIT CONTROL — the census could have found one. The two real authored permission sets in this tree (examples/app-showcase/src/security/permission-sets.ts, examples/app-crm/src/security/sales-positions.ts) carry 99 and 28 occurrences of live object-permission keys (allowRead / allowCreate / allowEdit / allowDelete / allowTransfer) in exactly the objects: { NAME: { ... } } shape this rule reads. The probe is aimed at files that really do carry object-permission blocks, and it returns a positive number on them — so the zero for the retired keys is an absence, not a miss. DARK CONTROL: a fabricated allowTeleport returns 0 in the same files, same expression.

There is also a structural reason the zero is not surprising, and it is worth stating because it bounds the rule's reach: every tracked objectstack.config.* in this repo declares its metadata in TypeScript code, and objectstack.json in this tree is the built artifact (dist/objectstack.json), not an author's source. The ruling's own warning — that the 181 carriers are fixtures and built artifacts, not sources — holds, and the in-tree source population beneath it is empty.

Severity: warning, and the measurement is what supports it

  1. A zero population is not an evidence base for a gate. There is no measured false-positive budget to spend and no in-tree carrier to prove the rule would refuse the right thing. error would be a refusal grade chosen on zero observations.
  2. error would reverse ruling D by the back door. The parse ACCEPTS allowRestore: false. An error at the authoring door makes os build refuse a stack the schema accepts — which is option B's accept-set narrowing, restricted to the CLI, and both feat(spec): retired-defaulted-key tolerance — the retired default parses as inert residue and strips; non-default values keep the loud refusal (#12497 class rule) #12840 and ruling D declined it. warning is the only grade that adds a signal without moving a gate.
  3. The registry's own tier rule agrees. gating means the rule can emit error and therefore must run on all three commands as a publish gate; advisory never emits error. This is advisory, and authoring-rule-wiring.test.ts reads the rule's own source to keep that claim honest.

Ruling D named warning as its expectation and conditioned the final choice on the measurement. The measurement supports it, so warning it is.

And the honest reading of what a zero population means for D itself: today this rule would fire on nothing in this repository. Its reach is authored JSON/YAML sources outside the tree — and the ruling already names the condition under which B re-opens as a new decision card, "AI-generated JSON that never runs lint". A lint rule cannot reach an author who never runs lint. That limit is not closed by this PR and is not claimed to be.

What the rule does

One rule, validateRetiredPermissionResidue, in packages/lint/src/validate-retired-permission-residue.ts.

  • Reads raw source, input: 'normalized' — the normalizeStackInput output, before any Zod parse. That tier is load-bearing rather than conventional here: the evidence is a key the residue stage removes, so a parsed rule would read a stack that structurally can never carry it.
  • Fires on the captured residue value and nothing else. true, 'false', 0 and null already land on the tombstone's own refusal with the prescription attached; repeating them here would be a second voice one layer earlier. The surviving enforced lifecycle bit, allowTransfer: false, is not residue and is never named.
  • Carries the prescription, read rather than retyped. retiredKey() publishes its guidance as the key's own description; the hint is resolved from ObjectPermissionSchema's shape at call time, so it cannot drift from the parse-time wording the same author sees through the other door. An unresolvable prescription yields no finding rather than a wording this module invented — the posture lintLivenessProperties takes to an unreadable ledger, which is why the test carries an anti-vacuity guard.

The finding splits the ruling's "message = the retired-key prescription" across the two fields the shared AuthoringFinding shape already has: message says what is wrong (the line is inert and silently stripped), hint is the prescription verbatim. Every other rule in the registry uses the same split, and the prescription reaches the author either way.

Registration, and which commands run it

Appended to AUTHORING_RULES in packages/lint/src/authoring-rules.ts — the existing table, no new mechanism. That one entry reaches os validate, os build and os lint (commands: ALL), which is also os compile's gate, since compile.ts makes the same runAuthoringRules('build', ...) call. surfaces: CLI_ONLY with a written surfaceReason: crossing to the runtime publish gate needs a measurement this round did not take — whether that gate's body reaches it BEFORE the per-type safeParse whose residue stage strips the only evidence this rule reads. Post-parse the rule is structurally silent, so wiring it there without that reading would publish a phantom check rather than coverage. The rule id constant is re-exported from src/index.ts, per rule-id-barrel-exports.test.ts.

Controls and ablation

The test carries paired controls throughout (packages/lint/src/validate-retired-permission-residue.test.ts, 17 cases):

  • LIT — the residue survives normalizeStackInput; the rule fires once per key with the right path and severity; it reaches an author through runAuthoringRules on all three commands, with the parsed tier deliberately handed a CLEAN stack so a fallback to parsed would be visible.
  • DARK — a clean permission set, a fabricated key, and every non-residue value earn nothing; the same runner is silent on a clean stack; malformed input never throws.
  • COST DIRECTION — allowTransfer: false, the surviving ENFORCED lifecycle bit, is the nearest miss in the shape (same family, same object, same false) and must never be named; flagging it would tell an author to delete a live grant.

Ablation, both legs proven on disk by occurrence count AND git hash-object before the run, restored against the HEAD blob after it, with a trap on absolute paths:

leg mutation verdict
guard removed the residue detection short-circuits vitest exit 1 — 3 failed / 14 passed, the LIT cases
cost direction widened to also match allowTransfer vitest exit 1 — 1 failed, exactly the COST DIRECTION case

Both legs restored: git diff HEAD empty and hash-object equal to the HEAD blob, checked rather than inferred from an exit code.

Tests and gates

run exit
pnpm --filter @objectstack/lint build + pnpm --filter @objectstack/lint test (lock VERDICT command-exit) 0 — 102 files, 3766 tests, 0 skipped
pnpm --filter @objectstack/lint typecheck (lock VERDICT command-exit) 0
eslint . --no-inline-config over the WHOLE repo population 0 — 6685 files, 0 errors, 0 warnings
88 derived gate families, run individually 85 exit 0, 3 NOT MEASURED

The three NOT MEASURED are check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt, each exiting 3 on its own PREREQUISITE NOT MET (they read built output the whole workspace has not produced here). A fourth, check:skill-examples, exited 1 with its own "Build first, then re-run" prerequisite text naming an unbuilt @objectstack/client-react whose build fails on its own unbuilt closure — a wrong-reason red, recorded as NOT MEASURED, not as red. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran accounts for all 88 with 0 UNRUN. The eslint reading above is not a narrowing: the full population ran.

No red anywhere.

Two gates went red on the first sweep and both are fixed in the second commit — they are the mechanical consequences of the change, not incidental cleanups. check:doc-authoring refuses an internal tracker id inside customer-facing string prose, so the reference moved from the surfaceReason string to the adjacent comment. check:docs-transcript-drift derives the author-time rule count from the registry and compares it against the transcripts the docs quote: the new entry moves it 44 to 45, so the four pages printing it are refreshed.

Changeset — measured, with dist BUILT

@objectstack/lint publishes dist only. dist was unbuilt at first reading (a real npm pack --dry-run returned 3 files: CHANGELOG, README, package.json), so it was built and the measurement retaken rather than argued from the declared tsup entries. With dist built, npm pack --dry-run returns 17 files, 14 of them under dist/, and all three new symbols are in the tarball:

  • validateRetiredPermissionResidue and PERMISSION_RETIRED_LIFECYCLE_RESIDUE — 6 published files each, including dist/index.d.ts and dist/index.d.cts
  • RetiredPermissionResidueFinding — the 2 declaration files
  • lit control: an already-published symbol, lintLivenessProperties, reaches 6 files. dark control: a fabricated symbol reaches 0.

Published surface moves, so a changeset is owed and present: .changeset/17425-retired-permission-residue-lint.md, graded minor (additive; nothing is removed and no existing finding changes shape or severity).

Declared overlap

Sibling card #17319's round has an open PR (#17912, awaiting review) that also adds a rule under packages/lint/src/ and edits the src/index.ts barrel. Declared rather than avoided, per this lane's ruled discipline: whoever lands second resolves. The barrel is an export list — on a conflict, merge main and re-add the export block.

Also declared: the file face grew past the claim's list. The claim declared packages/lint/ (rule, test, barrel). The diff additionally carries .changeset/17425-retired-permission-residue-lint.md and four content/docs/ pages, the latter because the derived rule count they quote moved. Amending the claim comment is the seat's act, not this round's.

Authored by Claude Code in session session_01MkQhmuuJAVDjmeWNixwDDH.


Generated by Claude Code

…g door

`ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false`
as inert residue and strips them in silence (#12840, the retired-default
residue tolerance — its ruling is not re-adjudicable and nothing here moves
it). The silence is deliberate so that artifacts built by the published 17.x
toolchain keep parsing, and `acceptRetiredDefaultResidue`'s own docblock names
the channels that stay loud for authored sources: tsc `never`, `os migrate
meta`, the ADR-0087 D2 conversion.

Against a non-TypeScript author that list is one entry short. `tsc never` is a
TypeScript channel; the conversion and `os migrate meta` are the same channel
twice, and it is declared `retiredFromLoadPath`, so it never fires on the load
path. An author writing the key in a JSON/YAML source and not running the
migration gets a clean parse and no signal at all.

Adds `validateRetiredPermissionResidue` — one advisory `warning` rule on the
`normalized` tier, registered in `AUTHORING_RULES` so `os validate`, `os build`
and `os lint` run it. It fires on the captured residue value and nothing else;
every other value is already refused at the parse with the prescription
attached. The hint is READ from the tombstone's own published description
rather than retyped, so it cannot drift from the parse-time wording.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…sh four CLI transcripts

`check:doc-authoring` refuses an internal issue id inside customer-facing
string prose — a runtime string reaches authors and generated surfaces, none
of whom can resolve it. The reference moves to the adjacent comment, where the
reader who can resolve it already looks.

`check:docs-transcript-drift` derives the author-time rule count from
`AUTHORING_RULES` and compares it against the transcripts the docs quote. The
new entry moves it 44 -> 45, so the four pages that print it are refreshed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 12 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))
  • content/docs/permissions/permission-metadata.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/permissions/permission-sets.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/permissions/permissions-matrix.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/protocol/objectql/security.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/releases/v17/17-3.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 7 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 — 4 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 5741ff10c3068a84e9099d3a3eb3b533054bbc50 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8ec7a6013530e3012bce229d6d3627ef158c7a8c — the merge of head d7d22bf4bebc4f0065b932556b07a9330d7822b2 into base 5741ff10c3068a84e9099d3a3eb3b533054bbc50, 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 8ec7a6013530e3012bce229d6d3627ef158c7a8c && git checkout 8ec7a6013530e3012bce229d6d3627ef158c7a8c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5741ff10c3068a84e9099d3a3eb3b533054bbc50 d7d22bf4bebc4f0065b932556b07a9330d7822b2 && git checkout -B drift-repro 5741ff10c3068a84e9099d3a3eb3b533054bbc50 && git merge --no-ff d7d22bf4bebc4f0065b932556b07a9330d7822b2

node scripts/docs-audit/affected-docs.mjs --json 5741ff10c3068a84e9099d3a3eb3b533054bbc50

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5741ff10c3068a84e9099d3a3eb3b533054bbc50 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-bill
os-bill marked this pull request as ready for review September 13, 2026 08:13
@os-bill
os-bill added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 6ec467b Sep 13, 2026
58 checks passed
@os-bill
os-bill deleted the claude/issue-17425-retired-permission-residue-lint branch September 13, 2026 09:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…tack-ai#18681)

Fixes objectstack-ai#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 objectstack-ai#17917 / card objectstack-ai#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 objectstack-ai#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:

```text
----- 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: 25d2042 (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
  objectstack-ai#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**):

```text
----- 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: 25d2042 (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
  objectstack-ai#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:

```text
----- 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: 25d2042 (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
  objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
  objectstack-ai#2 (i) token /repos/objectstack-ai/issues/18443 -> HTTP 200
  objectstack-ai#3 (i) token /repos/objectstack-ai/issues/18443/comments?per_page=100 -> HTTP 200 (4 row(s))
  objectstack-ai#4 (i) token /repos/objectstack-ai/objectstack/pulls/18659/files?per_page=100&page=1 -> HTTP 200 (1 row(s))
  objectstack-ai#5 (i) token /repos/objectstack-ai/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 objectstack-ai#18443
pair.1.head-sha: 1344eb5
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 **objectstack-ai#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`:

```text
HEAD blob                25d2042
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            25d2042   (HEAD 25d2042)
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 objectstack-ai#18448's
body independently cites
  as "the exact blob the 2026-09-13 readings were taken from".
- The governing claim on card objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 `objectstack-ai#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-`$?`:

```text
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.com/claude-code)

https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu

---
_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

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants