fix(pm): H19 judges a Blocked-by: block against the body's target set - #17807
Merged
Merged
Conversation
`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
os-sales
marked this pull request as ready for review
September 12, 2026 07:10
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17564
H19 (
scripts/pm/check-half-states.mjs) built itsBlocked-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.
blockerTargetsForstill 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.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:queueon that sentence while its real blocker was open and dispatched.So the rule is asymmetric, and only the asymmetry makes both false positives die:
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
Blocked-by: #14482(open); the parking comment5511304980also names#14483, closed 2026-09-07T12:50:37Z, and comment5595478312records thedomain:specseat 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 supersededBlocked-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.../objectuicarries a PINNED copy of this file (scripts/upstream-port-pin.json, refbf10debd, withDEFAULT_SWEEP_REPOa 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.REPO_OSfor exactly that reason:OWNER_REPOis read from the environment, and the ported copy runs its self-test under a differentGITHUB_REPOSITORY.h19BlockOutlivedBlockergained a third parameter (ownerRepo, defaulting toOWNER_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.tgt()keyed alocal: truetarget toobjectstack-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:Ablation ①, the fix removed —
carriesforced to() => true(union judging, the pre-fix behaviour). On-disk proof: anchor count 1 → 0, injected marker 0 → 1, blobec4f30aa→a4babf67.That is the measured row from this card, reproduced byte-for-byte by removing the fix.
Ablation ②, the asymmetry removed — precedence applied flatly to
openandunresolvedas well. On-disk proof: injected marker 0 → 1, blobec4f30aa→eb55cff2.⇒ 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 HEADempty,git hash-objectback toec4f30aa47c2c998fdca043eaaab5b0703d170c9).Live board, before/after. Two full sweeps,
01388fe81(this branch's base) and this branch's head, 06:31Z and 06:39Z: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 mergingorigin/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'sfiles[]moves.Authored by the ObjectStack skills-lane dev seat, session
session_01MCLBsUgfykL74aU716rzVK, againstscripts/pm/check-half-states.mjsat720d4a745.Generated by Claude Code