Skip to content

feat(pm): H59 reports a merged PR whose closing linkage mis-states a card it names - #17589

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-17466-merged-pr-closing-linkage-row
Sep 11, 2026
Merged

feat(pm): H59 reports a merged PR whose closing linkage mis-states a card it names#17589
os-litant merged 1 commit into
mainfrom
claude/issue-17466-merged-pr-closing-linkage-row

Conversation

@os-litant

@os-litant os-litant commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17466

One new report-only row in scripts/pm/check-half-states.mjsH59, over the merged-PR window the sweep already walks. Nothing else: no new workflow (the card's option 2 is refused), no second closing-keyword grammar, no label written, exit codes unchanged, no other row.

GitHub's PR-to-issue closing linkage is unreliable in both directions, so a merged PR mis-states the state of every card it names, and no seat can reason about closure from a PR's text. H59 reads the same merged window a second way and files a row for each direction.

The two directions, and their fixtures

(a) FALSE CLOSE — the expensive half. The PR body names a card by number, binds no closing keyword to that number, and the card went to the closed state with the merge. Work disappears from the board while reading as done, and it is the one failure no other row here can catch: every other predicate in this file reads open cards, so a card in the closed state is swept by nothing.

(b) FALSE OPEN — the cheap half. The body binds a closing keyword to a number and the card is still open after the merge. It surfaces as queue noise and somebody eventually re-reads the card.

Both fixtures in --self-test carry the filing card's two measured sibling-board shapes, written with this repo's own measured event shape (no commit_id, a 1–2s lag). The fixtures are offline literals; the self-test never touches GitHub.

(a)  PR #1810  body: "Part of #1807 … the follow-up this leaves behind is tracked on #1808."
     card #1808 goes to the closed state 2s after the merge, no window PR declared it  ⇒  row
(b)  PR #1811  body: a closing keyword bound to the number below (elided here so this PR
                     body declares nothing it does not mean); the number is #1792
     card #1792 still open after the merge                                             ⇒  row

⚠️ The card's verbatim quote records the sibling-board (b) instance as carrying no keyword either, while the dispatch describes it as carrying one. The row's predicate is the dispatch's — a bound number that stays open — so fixture (b) carries that shape rather than replaying that PR's body; the discrepancy is reported rather than resolved here.

The measured shape of a close-by-merge — and the assumption it killed

Zone-2's assumed evidence was the closed event naming the merge commit. Measured on this board before the predicate was written, it is not available at all:

card #17417   closed 2026-09-10T23:44:35Z   commit_id null   actor os-litant
              PR #17558, whose body's first line binds a closing keyword to #17417, merged 23:44:33Z
cards #16274 · #17081 · #17533 · #15141 · #17058 — same shape, 5 for 5
repo-wide issue-event stream, 1 page, 23:32Z→00:10Z — 7 `closed` events, 0 with a commit_id

So the commit leg is kept — it is exact wherever GitHub does stamp it, which a keyword in a pushed commit message does, and that is H23's surface — but it fires on nothing here. The temporal leg is what carries this board, and the ruling names it: the close sits inside the merge's own moment with no other actor.

The band is measured, and the ruling's "merge minute" was too wide. Over the same window, every (merged PR, card) pair where the body binds a keyword and the card's closure falls inside H22's window — 332 pairs, 321 within ±5 minutes of the merge:

0–2s  313      3–5s  7      31–60s  1        p50 1s · p95 2s · p99 4s

GitHub's own act lands in seconds. A full minute admits a different actor entirely, and this board has the specimen: card #16638 went to the closed state 53s after PR #16950 merged, by os-project-manager, with no window PR binding a keyword to it — a seat closing a card because the delivery landed, which is the protocol working. A 60s band reports it; the measured band does not. H59_MERGE_CLOSE_BAND_MS is 5s, which is the operational reading of the ruling's own 「with no other actor」, and the one 31–60s pair is named in the banner rather than rounded away.

The sibling guard. Merges land in batches — PRs #17558 and #17559 landed in the same second — so a card closing inside one PR's band may have been closed by a sibling that declared it properly. Measured at a 60s band there were three candidates; two were explained by a sibling's declaration and one was the seat close above. With the measured band and this guard the live board produces zero (a) rows.

The window, the narrowing and the cap

The population starts as H8's merged window — the same bounded listing, not a second one — and mentions are read with referencedNumbers, this file's one reference grammar. Measured on the live board (2026-09-11): 1,185 merged PRs, 5,830 references, 1,106 of them keyword-bound. A per-card read for each of the 3,282 unbound mention pairs is not a budget any sweep has, so the row narrows before it buys anything and every narrowing is counted rather than absorbed:

  • direction (b) costs zero requests — the open listing already in hand is the answer, and positive membership means a truncated listing can only lose a row, never invent one;
  • direction (a) takes closed_at from H22's closed-card window, so a mentioned-but-unbound card that is neither open nor inside that window is UNJUDGED and counted (linkageOutOfWindow), never clean. That makes this row's real reach the shorter of the two windows (3 days), and the clause says so instead of letting a reader assume eight;
  • only what survives both buys one timeline page, capped at H59_CARD_READ_CEILING = 40 cards per sweep with the shortfall printed. The page is one on purpose: the timeline is oldest-first, so a card whose history exceeds a page is UNJUDGED and counted (measured on six live cards: 16–40 events). ⛔ No retry loop on any status; a failed read is UNJUDGED and counted.

⚠️ This is the first per-card timeline fetch in this file. Four prose claims elsewhere in the file asserted that the sweep makes none; each is narrowed in this diff to stay true (H11/H15's label-age note and its rendered row, H55's banner and its rendered row, and listRecentIssueEvents' "only reader of event history", now "only repo-wide reader"). H13/H18's claims are scoped to the label-application event and are byte-identical — H59 reads a closed event and never a label one.

The live sweep

One report-mode sweep of this repo through the #13544 proxy re-exec, exit 0 (report-only, unchanged):

check-half-states: swept 487 open pm-/p0-labeled issue(s), 581 open issue(s) in the unscoped
pass, 25 open PR(s) and 1186 recently-merged PR(s) in objectstack-ai/objectstack — 324
half-state(s) found.

The new clause, verbatim:

Merged-PR closing linkage (H59): 1106 declared closure(s) and 898 keyword-LESS mention(s) from
the bodies in H8's merged window were judged against the open listing this sweep already holds;
a declared target still on that listing is a FALSE OPEN row and costs no request. 2815 further
mention(s) named a card that is neither open nor inside H22's 3-day closed window, so their
closure could not be PLACED and they are UNJUDGED rather than clean — this row's real reach is
the SHORTER of the two windows, ⛔ never H8's 8 days. Of the mentions that could be placed, only
a closure inside the measured 5s band buys evidence: 0 card(s) had ONE timeline page read (cap
40 card(s) per sweep, ⛔ no retry on any status), 0 could not be read and 0 were past that cap —
all UNJUDGED, never clean. A declaring SIBLING PR inside the same band EXPLAINS a closure and
this row stays quiet, because batched landings put two merges in one second. ⚠️ The `closed`
event carries no `commit_id` on this board (measured), so the commit leg is exact where it fires
and fires on nothing here; the band leg is circumstantial and every row says so. Rows are a
LOWER BOUND.

One row fired, and it is a real one on this board — direction (b), quoted as the sweep printed it:

H59 #15642 FALSE OPEN: merged PR #16022 binds `Fixes #…` in its body, and the card is STILL
OPEN after that merge — GitHub did not perform the closure the PR declared. …

(the keyword's number is elided above — it is the same card the row is filed against, #15642 —
 so that this PR body declares exactly one closure, the `Fixes` on its first line)

⛔ Zero (a) rows on this repo, which is exactly what the dispatch said it would not claim otherwise: the filing evidence is a sibling board's, and this sweep reports what this board has.

Ablation

Two legs, each: mutate → prove on disk (injected/removed grep -c plus a blob hash that differs from HEAD) → run → restore with git checkout HEAD -- ABS_PATH → prove restored (blob identical to HEAD, git diff HEAD empty) → re-run. A trap … EXIT INT TERM holds the restore on the crash path; the proof is the hashes, not the trap.

HEAD blob                                           fa880f26b595032795bc544093f4ca6ffa3fba18
control (unmutated)                                 exit 0 — 3559 cases pass

leg 1  drop the commit_id match in h59CloseAttribution
       injected count=1, removed count=0, blob d8b6c287… (differs)
       exit 1 — 3 of 3559 failed:
         H59 attribution: the COMMIT leg is exact when GitHub stamps it (got null, want "commit")
         H59 attribution: …and an abbreviated stamp still matches its own sha
         H59 (a): a COMMIT-leg row says the event named the merge commit instead
       restored blob fa880f26… identical to HEAD, git diff HEAD empty, exit 0 — 3559 pass

leg 2  drop the closing-keyword check in h59DeclaredCloses
       injected count=1, removed count=0, blob 239dc655… (differs)
       exit 1 — 7 of 3559 failed, in BOTH directions:
         H59 (b): the fixture PR really binds the keyword / the row quotes the binding as written
         H59 declares: ⭐ a NEGATED keyword still BINDS — the trap this card is about
         H59 sibling: a declaring PR inside the band EXPLAINS the closure  (the MISFIRE leg —
           without the keyword check the guard goes silent and an explained closure would
           become an (a) row)
       restored blob fa880f26… identical to HEAD, git diff HEAD empty, exit 0 — 3559 pass

Direction observed: turns red on leg 1, and on leg 2 turns red in both senses at once — the (b) fixture loses its row source and the (a) guard loses its silencer.

The one judgement, on the four axes

The judgement this card carries is the cap and the band (40 cards per sweep; 5s rather than a minute). 实际业务需求: both numbers come from a measurement of this board, not from taste — the band from 321 keyword-bound pairs (p95 2s) and the cap from the survivor set that reading produced (one card), and the wide reading was measured wrong, reporting a seat's correct act on card #16638 as a defect; "reads like it's useful" is exactly what a 60s band would have been. 项目长远合理性: the row rides windows and grammars the sweeper already pays for — one closing-keyword grammar, one reference grammar, H8's window, H22's window — so there is no second dialect to drift and no second schedule to die; the refused option 2 would have been new standing machinery with its own token and its own way of failing silently. 防 AI 写代码犯错: the failure is the declared-versus-enforced shape at its purest — a body that declares a closure the runtime did not perform, and a body that performs one it never declared — and the row refuses every lenient reading: an unplaceable closure is UNJUDGED, a card never read is UNJUDGED, a full timeline page is UNJUDGED, none of them clean, and the row says CANDIDATE where its evidence is circumstantial rather than asserting a mechanism. 创业阶段不扩散需求: no new workflow, no new label, no new state, no new regex and no new window — one row on a tool that already runs on a schedule, with both budget numbers set narrow by default and widening left as an argument someone has to make.

Verification

Gate families derived from the FINAL diff in the worktree with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths — the script derives its own change set; it reported 1 path(s) vs merge base 216b06682, three-dot). Every exit code captured before any pipe. All 39 derived commands ran; reconciled with --ran.

EXIT=0   all 39 derived commands (16 pnpm, 23 direct node), including:
EXIT=0     node scripts/check-closing-keyword-parity.mjs            (+ --self-test)
EXIT=0     node scripts/check-self-test-wired.mjs                   (+ --self-test)
EXIT=0     node scripts/check-scripts-symbol-anchors.mjs            (+ --self-test)
EXIT=0     node scripts/check-declaration-mirrors.mjs               (+ --self-test)
EXIT=0     pnpm check:pm-dispatch-gates
EXIT=0     pnpm check:pm-half-states
EXIT=0     pnpm check:partof-closing-keyword
EXIT=0     pnpm check:nul-bytes
EXIT=0     pnpm check:cross-package-test-inputs

Plus, by name:

EXIT=0   node scripts/pm/check-half-states.mjs --self-test
           before: "✓ check-half-states self-test: 3462 cases pass."   (on 216b0668)
           after:  "✓ check-half-states self-test: 3559 cases pass."   (+97: 96 H59 cases
                   plus one SUMMARY_CLAUSE_ANCHORS per-anchor case)
EXIT=0   node scripts/check-closing-keyword-parity.mjs
           verdict: "check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and
           both measured separators; sweep found 5 file(s) carrying the grammar across 8349
           tracked file(s), all registered)." — exactly the same carriers, no new spelling
EXIT=2   node scripts/check-closing-keyword-parity.mjs --body BODY_FILE   (run on THIS body)
           verdict: "2 closing declaration(s) …, bound by 2 of 3 registered parser(s)" — both
           of them the ONE `Fixes` on line 1, reported once per binding parser
           (duplicate-fix-guard and h7-partof-closing-keyword). Nothing else binds: every other
           keyword+number pair in this body is elided. Exit 2 is this mode's "a declaration
           exists" code, not a failure.
EXIT=0   node scripts/check-partof-closing-keyword.mjs (via `pnpm check:partof-closing-keyword`)
           verdict: "check-partof-closing-keyword self-test: 95 cases pass."
EXIT=0   pnpm check:pm-dispatch-gates
           verdict: "dispatch-gates self-test: 1678 cases pass."
EXIT=0   node scripts/pm/dispatch-gates.mjs --ran RAN_FILE --repo objectstack-ai/objectstack
           verdict: "39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED (a DERIVED
           zero — all 39 recorded an exit code and none of them is 3)."
EXIT=0   npx eslint --no-inline-config scripts/pm/check-half-states.mjs
EXIT=0   node scripts/pm/check-governed-merges.mjs --branch claude/issue-17466-…
           verdict: "✅  NOT governed — ordinary queue landing applies to a PR with exactly
           this file list." (0 of 1 path hit the register, 5 surfaces)
EXIT=0   node scripts/pm/check-half-states.mjs            (live report-mode sweep, this repo)

Repo-wide lint, not a narrowing: npx eslint . --no-inline-config --format json exited 0 over 6,595 files, 0 errors, 0 warnings, run at 35cf894b. The population and the counts are read from eslint's own run rather than declared, so no narrowing argument is owed — and for completeness, this repo never enables type-aware linting (calculateConfigForFile on the edited file reports parserOptions.project undefined; eslint.config.mjs says so in its own words), which is why a targeted run on this file would have been sound too.

⚠️ The repo-wide reading above is from the final commit's tree. Any later push re-runs it before the body is updated.

Not run locally, declared for CI: everything dispatch-gates itself named as outside the 39 — the 47 artifact-roster families (a fact about a list, not about these paths), the 11 wide-population families, the 10 pending-changeset families, the 4 workflow-value families and the 1 path-scheduled CI job. NOT MEASURED, not clean.

Changeset: none — scripts/pm/** publishes nothing from any released package. The skip-changeset label is applied.

Acceptance notes

  • noted, not filed: the card's verbatim measurement and the dispatch disagree on whether the sibling-board FALSE OPEN instance bound a keyword. Carrier: this PR's fixture note above; the next author of this row is who would trip over it. Not a defect in either artefact — the predicate is unambiguous.
  • noted, not filed: H22's closed-card window (3 days) is shorter than H8's merged window (8 days), so this row's (a) direction reaches only 3 days while its own population is defined over 8. It is disclosed in the clause on every run rather than hidden. Carrier: none today — widening H22's window is that row's decision, not this one's, and nothing else is blocked on it.
  • noted, not filed: the closed event's actor could discriminate a seat close from GitHub's own act if the PR's merger were in hand, but merged_by is absent from /pulls list rows and this row buys no per-PR detail read. The actor is printed in the row for the reader instead. Carrier: none — a detail read per merged PR is a budget question for a later card.

Clause-②: no

Authored in Claude Code session session_01YKEjmbYNvYWJvWGSWx26zK (durable attribution in prose, per the footer rule).


Generated by Claude Code

…card it names

GitHub's PR->issue closing linkage is unreliable in both directions, so a
merged PR silently mis-states the state of every issue it mentions. H59 reads
H8's existing merged window a second way and files a report-only row for each
direction:

  FALSE CLOSE  the PR mentions #N, binds no closing keyword to it, and #N
               closed with the merge. The expensive half: work disappears
               from the board while reading as done, and no other row here
               can catch it because a closed card is swept by nothing.
  FALSE OPEN   the PR binds a closing keyword to #N and #N is still open.
               The cheap half: it surfaces as queue noise.

Measured on the live board before writing the predicate: the `closed` event
carries no `commit_id` at all on this repo's landing path (six cards plus one
page of the repo-wide event stream, zero stamps), so the commit leg is kept
for the commit-message surface and the temporal leg is what carries here.
The band is measured rather than chosen - 321 keyword-bound pairs sit at
p50 1s / p95 2s / p99 4s - and the ruling's one-minute reading is narrowed to
5s because a minute admits a seat closing a card 53s after a merge. A sibling
PR that declared the closure inside the same band explains it and the row
stays quiet; without that guard two of three live candidates are wrong.

Report-only: exit codes unchanged, no label written, nothing reopened. The
remedy names the owning seat's act. Direction (b) costs no request; direction
(a) buys one bounded timeline page for the handful of cards that survive every
free narrowing, capped per sweep with the shortfall printed, and no retry on
any status.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] GitHub's PR→issue closing linkage is unreliable in both directions, so a merged PR silently mis-states the state of every issue it mentions

2 participants