Skip to content

fix(pm): H19 judges a Blocked-by: block against the body's target set - #17807

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-17564-h19-judge-against-the-body
Sep 12, 2026
Merged

os-sales merged 2 commits into
mainfrom
claude/issue-17564-h19-judge-against-the-body

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

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: Make the wall-clock unit un-misreadable in authored duration declarations — the ruled A-half of the business-hours question (#13655) #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 [finding] check-half-states H19 counts a superseded Blocked-by: target that lives only in a historic COMMENT — three seats have now hand-dispositioned the same false positive on #11333 #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 removedcarries forced to () => true (union judging, the pre-fix behaviour). On-disk proof: anchor count 1 → 0, injected marker 0 → 1, blob ec4f30aaa4babf67.

✗ 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 ec4f30aaeb55cff2.

✗ 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

`check-half-states.mjs`'s H19 discovered and JUDGED `Blocked-by:` targets
from one union of the card body and its comment thread. 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 thread and kept being counted, and the row published "the
block has outlived its blocker" against a card whose authoritative blockers
were all open.

Discovery still unions both channels (a blocker stated only in a comment is
still a blocker, and one of this row's two founding fixtures is exactly
that card). Judgement now takes the body's set whenever the body states
one, and the row names the carrier it judged.

Precedence governs the evidence of EXPIRY and nothing else: a CLOSED target
reachable only through the comment archive founds no row, while an OPEN or
UNRESOLVED one is counted whichever channel carried it and can only ever
withhold a row. The flat reading — drop every comment-borne target — would
have re-created the mirror defect a re-park produces (spent line in the
body, live blocker in a comment), whose measured cost was a card released
while its real blocker was open.

A superseded comment-borne closed target is named as HYGIENE in a clause on
a row that fired for another reason; it never becomes a row of its own,
because the remedy such a row would prescribe is unreachable.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants