Twinned from https://github.com/cfsh/ninefold/issues/660 by tools/beads/import_github.py's reconcile pass.
Suggestions from ninefold-reviewer's review of #633.
#633 merged at 15:12:01Z, ~90s after its final head 6f55057 was pushed. That head carries bfe9c50f — *"#646: pre-empt the unresolved-thread rejection"* — a fourth merge gate (Gh.unresolved_threads() + a new MergeRefused in merge(), ahead of the ledger) that landed after the last verdict and is described in no PR comment. The gate itself is correct and wanted: required_review_thread_resolution: true is live on ruleset 19101787. These are the surfaces that did not keep up. Filed unclaimed.
--why does not check the new gatetools/pr/merge_pr.py:explain() reproduces merge()'s refusals in order (state, CHANGES_REQUESTED, authority, mergeable, base, ledger) under a comment stating the invariant: *"⚠ Every refusal merge() makes, in the order it makes them."* bfe9c50f added a refusal to merge() (the gh.unresolved_threads() block) and not to explain(), so that comment is now false.
This is the F4 / F9 error direction a third time, and it costs more now: CLAUDE.md step 6 was rewritten by #633 to read *"⚠️ Unless merge_pr.py --why <n> says TIER 1"*, so --why is the documented entry point to the unattended path, not a diagnostic. A PR with one unresolved thread prints -> would merge on TIER 1, with no human approval, and the merge then refuses.
Fix: one gh.unresolved_threads() call between the base check and ledger_check() in explain(). WhyTest's _T1Gh already carries the threads knob, so the test is a two-liner.
notes/merging.md is where CLAUDE.md step 6 sends a session, and its "The three gates" section still enumerates three — ledger, presubmit, UI gate — with no mention of thread resolution. merge_pr.py's own ## The flow header is likewise unchanged. Same shape as F3 → F7 → F13 → F14: fixed in the module, absent from the file that outlives the thread.
Two things a reader needs there:
resolveReviewThread over GraphQL — not reachable from gh pr. The ledger hands the operator a literal reply-cmd for exactly this reason; refuse: … Resolve them on the PR first. is all a blocked session gets today.notes/merging.md currently tells it to and still be refused.unresolved_threads()'s fail-soft contract does not exist in productiontools/nflib/gh.py:unresolved_threads() and its call site both rest on *"None means the query failed … Proceed on an unreadable count rather than blocking every merge on a GraphQL hiccup"*.
Gh.json does return default on not result.ok — but raw() keeps check=True and merge_pr.main() builds Gh() on the strict runner, so a failing gh api graphql raises ProcError two frames earlier and main() prints refuse: a GitHub call failed, so the merge cannot be gated safely. The merge is blocked, the opposite of what the comment promises.
The behaviour is fail-*closed* and therefore safe; the claim is wrong, and a future reader could simplify against a false premise. test_an_UNREADABLE_count_proceeds_rather_than_blocking cannot catch it — _T1Gh overrides unresolved_threads to return None directly rather than exercising Gh.unresolved_threads through a failing runner. main()'s own except ProcError comment, 170 lines below, names this exact unreachability class for ledger_check's fail-closed login branch.
Fix: either say the strict runner already covers it and delete the promise, or make the branch real (a lenient runner for this one call).
tools/perimeter_census.py still prints corpus: {n} merged PRs (gh, last {limit} merges) off the same creation-ordered gh pr list that --audit just stopped describing that way, and perimeter.py's docstring reads it back as *"the 200 PRs merged 2026-07-22 → 2026-08-06"*. Milder than the audit case — this repo merges fast, so the orders nearly coincide — but the census is that docstring's evidence.merge_pr.changed_files() does row = entry if isinstance(entry, dict) else {}, but Gh.pr_files() already filters if isinstance(r, dict) — so a falsy non-dict row still vanishes before changed_files() sees it, rather than becoming the pathless entry classify() refuses. Recovered by the changedFiles cross-check whenever the count is readable.reviewThreads(first:100) is unpaged and has no pageInfo check — the silent-window shape of F15, one API over. Fail-open in direction; the server still enforces the rule, so the cost is the pre-bfe9c50f behaviour.Gh.unresolved_threads interpolates self.repo.split("/")[0] / [1] into the query unguarded; REPO=foo raises IndexError, which main() does not catch.16 / 200 (8%) with no truncation line, where the census now reports 18 / 200 (9%) plus ⚠ 1 PR(s) had a TRUNCATED file list … 161. perimeter.py's docstring was dated to the 14:05 corpus in that same commit; the body was not.Refs #381 only. #633 carries the durable fix for #651 / #646 (option B) and #646's second finding; #646's closing comment asked for a Closes or a reason to outlive it. Both issues are closed as duplicates of each other, so nothing is stranded.None.
No comments.