Commit 1e5b5e0
fix(pm): report a claim that parses to ZERO branches instead of discarding it (#17766)
Fixes #17149
A `Claim:` comment that parses to ZERO branches is now reported as a
MALFORMED claim instead of being discarded. The deliverable is triage
5620657752's, verbatim: 「⛔ do not fix the third spelling. **A parser
that silently yields zero must say so** — an unparsed claim is an
*unclassified* result, ⛔ never a *no*.」 Option 1 on the card (widening
`claimedBranches` for the inline spelling) is **not** done here, and the
branch reader's accept set is byte-identical — pinned as a case, because
that is what keeps the NEXT unrecognised spelling loud on its first
occurrence.
## The mechanism
`governingClaim` picked the newest claim comment from which at least one
branch parses, and `if (branches.length === 0) return;` threw the rest
away. A discard is indistinguishable from an absence, so governance fell
back to an older claim and said nothing.
`claimGovernance(commentRows)` is now the two-part reading:
- `governing` — what `governingClaim` has always returned.
`governingClaim` is a one-line wrapper over it, so the ~18 readers that
legitimately need only branches are untouched (proved below: the
self-test's 3616 pre-existing cases pass unchanged on the refactor
alone).
- `malformed` — the NEWEST claim-shaped comment when it parses to zero
branches, carrying its comment id, its timestamp, and the `created_at`
of whatever governance fell back to.
Recency is written once and both halves call it, so the two readings can
never disagree about which comment is current.
## Every reader says so
| reader | before | after |
|---|---|---|
| H20 / H27 dispatch liveness | probed the superseded branch, or
(nothing parses) went silent as "no claim" | **H60**, a new `state`-band
row, fires for every open `pm:dispatched` card, naming the comment id,
what governance did instead, and the remedy |
| `check-clause2-carriers` declaration limb | read the superseded
claim's `Clause-②` line as if current | `cardDeclaration` returns
`claim-branch-unparsed` **before** any line is read, from any comment
(including the #17366 correction path) |
| `--pair N` | exit 4 with a verdict taken from the wrong comment |
**exit 2 (UNJUDGED)**, with the whole reading printed |
| the fold/lane rosters (H37/H38) | built on `governing` | unchanged —
they need branches only, and H60 is the row that says the roster may
rest on a superseded claim |
**The exit code is the file's own, not a new one.** Its table already
reads 2 as UNJUDGED — "an unread carrier is NOT a bare carrier and an
unread thread is NOT an absent declaration (#4690)" — and an
unresolvable carrier is exactly that. Rendering it as 4 would make an
unclassified result an adverse verdict, which is the reading the card
refuses by name. Rendering it as 0-with-a-message is what the whole file
exists against.
## The three questions the card asked
### 1. Where does the guard belong — carriers, half-states, or both?
**Both, from one source.** The state is produced once in
`check-half-states.mjs` (which owns `CLAIM_COMMENT_MARKER` and
`claimedBranches`) and consumed in both files. A second detector in the
carriers file is the drift that file's own docblocks refuse by name
("imported rather than restated ... so the two readers cannot drift").
### 2. Which end?
Option 2 only. See the four-axis reading below.
### 3. Is the fleet's claim template the cause? — MEASURED on the live
board
Read 2026-09-12T03:4xZ, REST, repo-scoped, over every open
`pm:dispatched` card:
- **28** open `pm:dispatched` cards.
- **5** of them (18%) have a newest claim-shaped comment that parses to
ZERO branches: **#16310, #16268, #16251, #16175, #15234**.
- 22 parse; 1 (#13597) carries no claim-shaped comment at all.
Four of the five claims were posted by ONE session
(`session_012GKcPZbMoGq7WPzKLfRBTU`, the `domain:devx` execution PM
seat) within **three seconds** of each other, 02:53:05Z–02:53:08Z, all
in the same shape:
```
Claim: session_012GKcPZbMoGq7WPzKLfRBTU · claude/issue-16310-orphan-locale-key-gateable
Clause-②: yes
```
That is not a run of typos; it is a template emitting a carrier no
reader accepts. **#16175 is the silent-fallback shape, live today**: its
newest claim (5642984850, 2026-09-12) parses to zero, so governance
falls back to its 2026-09-06 claim (5557414924) — which names a
**different** branch, `claude/issue-16175-regen-sibling-stale-rules`
against the current `claude/issue-16175-staleness-mtime-false-refusal`.
Every downstream reader is probing the wrong ref, and the two `Clause-②`
values happen to AGREE, which is the #16589 near-miss recorded on #16322
reproducing itself.
⛔ No `.claude/**` edit is made from this card, per the dispatch. The
template finding is handed to the skills-lane seat; after this PR lands,
those five cards are visible rather than silent, which is the point.
## Four-axis reading of option 1 vs option 2
- **实际业务需求** — measured, not speculative: 5 live carriers today, plus
the #16322 cost (two rounds, a director re-review, a re-issued claim)
and its near-miss sibling. Option 1 addresses the one spelling in front
of us; option 2 addresses the population that produces them.
- **项目长远合理性** — option 1 is the treadmill the card names and #16170
already bought once; each widening buys one spelling and leaves the next
silent. Contract-first says the defect is at the reader's CONTRACT (a
two-valued answer where three states exist), not in its accept set.
- **防 AI 写代码犯错** — decisive here. Option 1 is consumer-side tolerance —
the exact shape the axis forbids, and its failure mode is the silent
one. Option 2 is a loud refusal at read time: a claim written in a shape
the protocol does not accept is refused with the remedy named, and the
seat cannot declare a claim the tooling does not honour.
- **创业阶段不扩散需求** — option 2 adds no capability surface: one new reading
of data already in hand, no new request, no new exit code, no new label
written. Option 1 would grow the accept set permanently for a spelling
the standing rule already forbids.
⇒ Option 2, on all four. Option 1 is deliberately NOT also done: doing
both would let the accept set absorb the measured spelling and leave the
new state unexercised on the live board, which is the one way to ship
this fix and still not know whether it works.
## Verification
All exit codes captured before any pipe (`cmd > log 2>&1; EXIT=$?`).
| command | verdict line | exit |
|---|---|---|
| `pnpm check:pm-half-states` | `✓ check-half-states self-test: 3656
cases pass.` (was 3616) | 0 |
| `pnpm check:pm-clause2-carriers` | `✓ check-clause2-carriers
self-test: 493 cases pass (...)` (was 465) | 0 |
| `pnpm check:pm-dispatch-gates` | `✓ dispatch-gates self-test: 1678
cases pass.` | 0 |
| the derived union, 41 commands | `✓ dispatch-gates --ran: 41 derived
famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero — all
41 recorded an exit code and none of them is 3).` | all 0 |
The union was re-derived and re-run **after** the final commit, on head
`b2b55c2b4` (`git rev-parse --short HEAD`), with `--repo
objectstack-ai/objectstack` asserted. The first derivation printed a
STALE TREE clause naming `check-skill-line-ratchet.mjs` and
`check-widening-tells.mjs` (PR #17760 had landed); `origin/main` was
merged in, the list re-derived byte-identically at 41, and every command
re-run on the merged head. Reconciliation was fed `command :: exit N`
lines so the zero is derived, not claimed.
1 parent 6548118 commit 1e5b5e0
2 files changed
Lines changed: 527 additions & 33 deletions
0 commit comments