tooling(pm): close H45's parent-relation silence — a pm:epic card reserved under nothing delegated - #15755
Conversation
…eserved under nothing delegated
H45 reported one shape (`pm:epic` + `pm:queue` on one card) and documented the
one it could not afford: a `pm:epic` card whose PARENT does not carry the label.
Both prices that paragraph named are now paid and declared.
- a new, named fetch class: `subIssueParentPath` — one
`GET /repos/{owner}/{repo}/issues/{n}/parent` per `pm:epic` carrier, whose
404 `No parent issue found` is the documented ANSWER for a parentless card
and is counted as a probe that answered. No other sub-issues endpoint.
- a SEPARATE `pm:epic` enumeration, read last in the sweep and never folded
into `seen`. `SEEN_LABEL_PAGES` lifts the seven pages that do feed `seen`
out of the loop body so the exclusion is checkable rather than re-typed.
- the row files under H45, one row id and a second sentence: the registry
keys are `H<digits>` and admit no sub-rows.
- a top-level carrier is the delegated node when EITHER free signal says so —
it heads a subtree, or its body carries the `session_…` registration. The OR
is what the live index forced in both directions.
- a coverage pair (`epicParentCandidates` / `epicParentProbed`) and an
unconditional summary clause, so an unread carrier reads as UNJUDGED.
H45's first half is untouched: same predicate, same sentence, same free
population. Report-only exit semantics unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…lf-states-h45-parent-half
|
ACCEPT — in-seat review of PR #15755, head Implemented-by: Verified in a detached compare worktree at the PR head, three-dot against Seat gate runs on this head in the compare worktree: Landing regime: pure code ⇒ ready + auto-merge in this same act (the queue's SQUASH governs whatever method the tool reports); Generated by Claude Code |
Fixes #15702
H45 reported one shape (
pm:epic+pm:queueon one card) and carried a paragraphnaming the reading it could not afford: a
pm:epiccard whose PARENT does not carrypm:epic. Both prices that paragraph named are now paid, declared, and measured.Surface:
scripts/pm/check-half-states.mjsonly. Report-only, exit semantics unchanged.H45's first half is byte-identical — same predicate, same sentence, same free population.
The two contract questions, as implemented
subIssueParentPath(repo, n)is named and pinned the way
pmLabelListingPathnames the label pages — a requestshape inlined in a loop body can only be asserted by re-typing it. One
GET /repos/{owner}/{repo}/issues/{n}/parentperpm:epiccarrier. Its 404No parent issue foundis the endpoint's documented answer for a parentless card, sothe caller counts it as a probe that ANSWERED; any other failure leaves the carrier
UNJUDGED. No other member of the sub-issues family is called.
pm:epicenumeration is a separate population. It is read LAST insweepInto,into a loop variable, and never enters
seen.SEEN_LABEL_PAGESlifts the seven pagesthat do feed
seenout of the loop body so the exclusion is checkable from outside thefunction instead of re-typed into an assertion. Evidence for "no other row's input
moved" is in two forms below: a self-test ablation, and five live sweeps.
The row files under H45 — one row id, a second sentence. Every registry key matches
/^H\d+$/(familyNumberparses it,familyRegistryCoveragescans for it), so there are nosub-rows to name; a case pins that no key of another shape exists.
The reading taken on
pm:epicsemantics, and why.claude/skills/pm-dispatch/SKILL.md,## Epic 子树车道, makespm:epica two-node fact:it sits on the delegated 父单 AND on every sub-issue, and 「
label:pm:epic即父子保留全集」makes the index parents and children together. The reservation it publishes is the same
sentence at both ends: 「已由 epic PM 保留;其它 PM 永不取」.
So the row asks whether the node the reservation hangs from is delegated:
pm:epic-> clean.delegated").
carrier is the delegated node when EITHER free signal says so: it heads a subtree
(
sub_issues_summary.total > 0, which is what a 父单 is), or its body carries asession_…identifier — the machine half of the paired delegation signal「父单打
pm:epic+ 正文写会话 ID 与声明的文件领地」, and the same registration thezombie rule reads (「父单正文有登记」). Only a carrier with NEITHER is reported as
"reserved under nothing delegated".
rather than silently excused, with no side taken.
The OR is not a preference — the live index refuses both single-signal readings, in
both directions (measured 2026-09-05 06:1xZ, table below): #11632 is a chartered epic with
a live owner registration in its body and ZERO sub-issues, so a sub-issue-only reading
calls it abandoned; #12243 and #11663 head 20 and 11 sub-issues with no session id in the
body, so a registration-only reading (or an AND) calls them abandoned. Under the OR all
five are clean, which is what the board actually is today.
The body half is a deliberately LOOSE anchor, and its looseness is safe in exactly one
direction: it is consulted ONLY to EXCUSE a top-level carrier, never to fire one. Its
error mode is a silence (stated in the banner and in the finding sentence, which calls the
row a LOWER BOUND), never the phantom accusation that keeps the seat-sticker body half out
of this file entirely.
Live run — both repos
Request cost is exactly one label page plus one parent read per carrier, and the sweep's
own open-listing counter shows it: objectstack 17 -> 18 pages, objectui 14 -> 15 pages.
objectstack — 5 carriers, 5 parent reads, 0 flagged.
objectui — 0 carriers, 0 parent reads, 0 flagged; the clause renders unconditionally
(
0 of 0), so an empty index reads as an empty index and never as a pass that skipped one.Sweeps:
PM_SWEEP_REPO=objectstack-ai/objectstackexit 0, 257 findings (05:47Z);PM_SWEEP_REPO=objectstack-ai/objectuiexit 0, 157 findings (05:55Z)."No other row's input moved" — the evidence
Structural (the primary claim). The
pm:epiclisting is consumed by aforloop atthe very end of
sweepInto, after every other row has finished judging. There is no pathby which one of its rows can reach
seen, the shared comment cache, or the referencecorpus.
SEEN_LABEL_PAGESis frozen, exported and pinned.Ablation (the claim can fail). Three legs, each mutated on disk, proven on disk (deleted-
text and injected-text occurrence counts plus the blob hash against
HEAD), run, thenrestored from
HEADwith the restore proven the same way (git diff HEADempty AND theblob hash back). No build step sits between the mutation and the run: the self-test imports
the same source path the mutation writes.
pm:epicbranch returns nullseenpm:epicadded toSEEN_LABEL_PAGESThe population cases are the ones that matter here, and they carry a CONTROL: a case
asserts that an epic carrier folded into the judged population really WOULD fire an
existing row, so the "verdicts unchanged" pin cannot be satisfied by a population that
could never have mattered.
Live before -> after. Five interleaved objectstack sweeps, per-row finding counts:
18 rows are identical in all five runs. Every mover is a row over live PR/branch/seat
state, and each moves between two PRISTINE runs as well, which is the control:
#6023, whose row read "aClaim:written 0.0h AFTER this post's last event"— an order comparison on a boundary, flipped by that seat editing its post.
* The two starred runs executed the pristine file from OUTSIDE the repo, where H43's
governed register is not beside it. Those runs say
NOT MEASURED — the governed register is not installed beside this filein their own words, so H43 is comparable only acrossthe in-place pair, where it reads 5 = 5. This is the file's own prerequisite discipline
catching a defect in my measurement method rather than in the board; the in-place pair
(05:40 pristine / 05:47 patched, same tracked path, swap and restore both proven by blob
hash) is the one that carries the before -> after claim.
objectui, same pair shape: 20 of 22 rows identical; the two movers are H16 (three PRs
entered merge conflict in the five-minute gap — and the run with MORE conflicts is the
PRISTINE one) and H11, off by one.
Self-test
node scripts/pm/check-half-states.mjs --self-test->✓ check-half-states self-test: 2262 cases pass.(2223 before this change; +39 written here, +1 generated by the sharedSUMMARY_CLAUSE_ANCHORScoverage loop picking up the new anchor).Cases in both directions, one per shape ruled: parent carries
pm:epic(clean) · parentlacks it (finding, names the parent, names the shape, asks for ONE label write) ·
top-level heading a subtree (clean) · top-level with a body registration (clean) ·
top-level with neither (finding, says "no parent" and never invents a parent number) ·
each delegation signal read on its own · a body with no session id is not a registration ·
404 against the card's own
parentobject (finding) · the same viaparent_issue_url·the contradiction outranking the delegation excuse · a card without
pm:epicbuying noread and being declined even when handed a parent · an unread parent filing no row, with
the summary clause carrying the UNJUDGED disclosure · both adjacency directions against
H45's first half · the fetch-class path · the population separation and its control · both
count keys on
SWEEP_COUNT_KEYS· the summary clause rendered unconditionally, withnumbers rather than
undefined.On the declared floors named in the dispatch: this file declares no battery floor —
the roster/floor mechanism lives in
scripts/sync-template-versions.mjs(
SELF_TEST_BATTERY_FLOOR), not here, and nothing in the repo pins a case count for thisscript. The count line is computed from
cases.lengthand moved 2223 -> 2262. The"79-case" figure in
.github/workflows/lint.ymlis a historical statement about what thebattery held when that step was added, not a floor.
Header registry: this file's per-row documentation lives in the banner above each
predicate (the prose list in the file header stops at H28 and has since). The H45 banner's
"what it deliberately does NOT report" paragraph is replaced, and the second half has its
own banner beside it.
Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderivedthe family from the real changeset (1 path, three-dot against merge base
c2a336ca2), andall 31 commands were run. 30 exit 0. The one non-zero is not a verdict:
It was then run properly, with this body in
PR_BODY— see the check line below.pnpm lint(eslint . --no-inline-config, the whole repo, not a narrowed run) through theshared verify lock:
os-verify-lock: VERDICT command-exit 0 · held the lock 102s.All of the above was measured on
3b29a22f6, this branch's tip at the time of writing(
origin/mainmerged in first, so the derivation reads a current tree).This PR is a draft and stays one: the seat reviews and flips it. Not governed; no publishable
package changes, so
skip-changeset.Generated by Claude Code