Skip to content

Commit 2070a1d

Browse files
claude[bot]claude
andauthored
fix(pm): H19 judges a Blocked-by: block against the body's target set (#17807)
Fixes #17564 H19 (`scripts/pm/check-half-states.mjs`) built its `Blocked-by:` target set as one union of the card **body** and its **comment thread**, and then both *discovered* and *judged* against that union. The two carriers are not equal in authority — **a body is rewritten in place, a comment is an archive** — so a target a seat superseded by refreshing the body line kept living in the historic comment forever and kept being counted, and the row published 「the block has outlived its blocker」 against a card whose authoritative blockers were all open. That row fired on #11333 on every sweep from 2026-08-31 to 2026-09-12 and was hand-dispositioned **four** times by three different seats. The archive it reads is the record of an *earlier* H19 row being repaired, so the repair manufactures the next false positive and no work on the card can ever clear the row. ## What changed **Discovery is untouched.** `blockerTargetsFor` still unions both channels, and every consumer of that union — the resolver and its per-target cache, H26, H28 — still sees every target a card names in either channel. A blocker stated only in a comment is still a blocker, and one of H19's two founding fixtures is exactly that card. **Judgement takes the body's set** whenever the body carries any `Blocked-by:` line, and the comment-borne set only when it carries none. The row text now names the carrier it judged. ### ⚠️ Precedence governs the evidence of EXPIRY, never the evidence of a WAIT Applied flatly — "drop every comment-borne target, count what is left" — the rule fixes this card's false positive by re-creating a worse one that was already measured and already paid for. #11747's shape is the **mirror** of this card's: a RE-PARK leaves the SPENT blocker in the body and the LIVE one in a comment, and a body-only count then publishes 「every target it names is closed」 — a card was released to `pm:queue` on that sentence while its real blocker was open and dispatched. So the rule is asymmetric, and only the asymmetry makes both false positives die: | reading | what it can do | |---|---| | CLOSED, body-borne (or comment-borne on a body-less card) | can **found** a row | | CLOSED, comment-only beside a body set | a spent line — founds nothing | | OPEN, either channel | can only **withhold** a row | | UNRESOLVED, either channel | can only withhold, and says so | This is the posture #4690 already fixed for the third target state — ⛔ never resolve an ambiguity in the direction of release — applied to the carrier question instead of the readability one. It is also why an UNRESOLVED target is not silenced by precedence: it is by this file's own vocabulary *not judged*, it only ever withholds, and dropping it would re-create the silence the three-state split exists to end. H19's UNJUDGED handling is otherwise untouched (#17512 carries the 404 half). ### The card's shape-3 sentence rides a row; it never becomes one A CLOSED target reachable only through the comment archive is named as a **HYGIENE** reading in one appended clause on a row that fired for another reason — ⛔ never an unlock candidate. It costs no second predicate: the channel split is `blockerChannelKeys`, the set H28 already computes from bodies this sweep has read, and every resolution outside it is comment-borne by construction. ⛔ Where H19 does **not** otherwise fire it emits **nothing** — no standalone hygiene row. That is deliberate and is the whole point of the card: the remedy such a row would prescribe is unreachable (the body is already correct; the comment belongs to another account and is history either way), and 「a row no reader can ever clear trains readers to skip the family」. Trading a false unlock row for an unclearable hygiene row would have bought nothing. The clause is therefore only ever readable beside an actionable finding. ## Scope ⛔ Not shape 2 (needs the body's edit history the sweep does not fetch). ⛔ No change to `blockedByTargets` / `commentBlockedByTargets` — the index readers depend on them. ⛔ Not H26's transitive rows, ⛔ not H4, ⛔ not H22's docblocks. ## Acceptance notes - **A second live card carries the same defect, found by the before/after sweep and independently confirmed: #13801.** Its body names `Blocked-by: #14482` (open); the parking comment `5511304980` also names `#14483`, closed 2026-09-07T12:50:37Z, and comment `5595478312` records the `domain:spec` seat migrating the live blocker into the body on 2026-09-09 — the same "the repair manufactures the next false positive" loop, on a card #17564 explicitly did not claim to have swept for. Cleared by this change with no action on the card. Noted, not filed: the defect is this PR's, and the card needs nothing. - `../objectui` carries a PINNED copy of this file (`scripts/upstream-port-pin.json`, ref `bf10debd`, with `DEFAULT_SWEEP_REPO` a declared divergence). Its gate compares its own copy against its own stored digest and fetches nothing, so this change cannot redden it; the fix reaches that board only through a deliberate re-sync. Noted, not filed — taker: whoever next bumps that pin. ⚠️ The new self-test cases are handed an explicit `REPO_OS` for exactly that reason: `OWNER_REPO` is read from the environment, and the ported copy runs its self-test under a different `GITHUB_REPOSITORY`. - `h19BlockOutlivedBlocker` gained a third parameter (`ownerRepo`, defaulting to `OWNER_REPO`) so the carrier split can be keyed against the same repo the resolutions were. The sweep's own call site is unchanged and still takes the default. - Two H26 self-test fixtures were incoherent and are corrected in the same edit: `tgt()` keyed a `local: true` target to `objectstack-ai/cloud#N`, and the both-rows fixture resolved two targets on a card whose body declared one. Neither could be true of a real resolution, and both were only ever invisible because nothing read the card's body beside its resolutions. ## Verification **Self-test** (`pnpm check:pm-half-states` = `node scripts/pm/check-half-states.mjs --self-test`), counted in-tree: ``` before (origin/main's file restored into this tree) ✓ 3656 cases pass. exit 0 after (this branch) ✓ 3683 cases pass. exit 0 (+27) ``` **Ablation ①, the fix removed** — `carries` forced to `() => true` (union judging, the pre-fix behaviour). On-disk proof: anchor count 1 → 0, injected marker 0 → 1, blob `ec4f30aa` → `a4babf67`. ``` ✗ check-half-states self-test: 8 of 3683 case(s) failed. exit 1 ✗ H19 precedence (a): the live #11333 shape emits NO row (got "`pm:blocked` while 1 of 3 `Blocked-by:` target(s) … is CLOSED (`#12400` (closed 2026-08-29T15:16:12Z)): the block has outlived its blocker …", want null) ``` That is the measured row from this card, reproduced byte-for-byte by removing the fix. **Ablation ②, the asymmetry removed** — precedence applied flatly to `open` and `unresolved` as well. On-disk proof: injected marker 0 → 1, blob `ec4f30aa` → `eb55cff2`. ``` ✗ check-half-states self-test: 9 of 3683 case(s) failed. exit 1 ✗ H28 repro (NEW, ungated): H19 reads 1 of 2 ✗ H28 repro (NEW, ungated): …and calls it a PARTIAL discharge ✗ H28 repro (NEW, ungated): …naming the live blocker as still open ✗ H28 repro (NEW, ungated): …and no longer claims every target closed ``` ⇒ the flat reading re-creates #11747, whose measured cost was a card released while its real blocker was open. Both ablations restored from the committed state and proven byte-identical (`git diff HEAD` empty, `git hash-object` back to `ec4f30aa47c2c998fdca043eaaab5b0703d170c9`). **Live board, before/after.** Two full sweeps, `01388fe81` (this branch's base) and this branch's head, 06:31Z and 06:39Z: ``` H19 rows before 23 after 21 gone: #11333 ("1 of 3 … is CLOSED (#12400 closed 2026-08-29T15:16:12Z)") #13801 ("1 of 2 … is CLOSED (#14483 closed 2026-09-07T12:50:37Z)") unchanged: #11973 #15140 #15214 #15430 #15642 #15669 #15712 #15788 #15829 #16023 #16354 #16465 #16468 #16695 #17157 #17451 #17452 #17487 #17541 #17561 #17612 new: (none) ``` 19 of the 21 surviving rows are byte-identical once the inserted carrier clause is normalised away; the other two (#15140, #15214, both UNJUDGED cross-repo) differ by that clause alone — same verdict, same counts, same targets. ⚠️ The two sweeps ran ~8 minutes apart on a LIVE board, so the deltas in H8/H20/H30/H44/H50/H52/H56 are cards that moved in between (#17098, #17620, #16270, #17672), not reach of this change — which touches one function. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; it read the change set itself) derived 40 commands; all were run with the exit code captured before any pipe. All 40 exit 0. The union was re-derived and re-run on the FINAL head — `720d4a745`, after merging `origin/main` — and `--ran` (each line recorded as `<command> :: exit <code>`, `$?` captured before any pipe) reconciles **40 derived / 40 run / 0 NOT-MEASURED / 0 UNRUN**, a DERIVED zero: every one of the 40 recorded an exit code and none is 3. No STALE TREE on that derivation; the change set is still one path. Verdict lines: `✓ check-half-states self-test: 3683 cases pass.` · `✓ check:nul-bytes` · `✓ dispatch-gates --ran: 40 derived famil(ies) accounted for`. `skip-changeset`: `scripts/pm/**` is on the declared fast track — nothing in any package's `files[]` moves. Authored by the ObjectStack skills-lane dev seat, session `session_01MCLBsUgfykL74aU716rzVK`, against `scripts/pm/check-half-states.mjs` at `720d4a745`. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26e0d81 commit 2070a1d

1 file changed

Lines changed: 242 additions & 15 deletions

File tree

0 commit comments

Comments
 (0)