Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- `clear-escalations.mjs` `LANE_CAUSES.verdict` adds `verify:repeated-fail` and `verify:too-old`, so under `merge.lane-prs: native` a `needs-human` label `enable-auto-merge` placed for either comes off at its head (`refusal-cleared`) once the reader names only lane causes there. `lane-causes-person-codes.test.mjs` also pins `LANE_CAUSES.verdict` against `babysit_verify_evidence.NEEDS_HUMAN`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
bump: patch
---

### Changed

- `babysit_verify_evidence.NEEDS_HUMAN` is `verify:untrusted-spec` alone (owner rule 2026-10-27: a person only for an intent or design change or a CRITICAL security issue, trust holds kept). `verify:repeated-fail` and `verify:too-old` still refuse the merge for good, now with no `needs-human` label: the pr-merge gate writes no escalation for them, and `enable-auto-merge.mjs` leaves the pull request unarmed with the lane comment and, once it stands, a failing `pr-merge/unarmed` status.

### Removed

- The repeated-fail escalation's quote of the head FAIL's summary, now unreachable: `babysit_merge._fail_summary`, `_carried_quote` and `VERIFY_SUMMARY_MAX`, `Reader.fail_summary`, `SummaryUnreadable`, and the report's `headFail` field.
16 changes: 9 additions & 7 deletions .github/scripts/pr-merge/clear-escalations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,21 +144,23 @@ const REFUSAL_CLEARED = "refusal-cleared";
// this script lifts at the same head, and the only ones a lift tolerates
// refusing now. Every other code, an unknown one included, is a person's. The
// list is explicit, so it fails closed; lane-causes-person-codes.test.mjs pins
// it against babysit_risk.PERSON_CODES and PERSON_PREFIXES (#10249).
// it against babysit_risk.PERSON_CODES and PERSON_PREFIXES (#10249) and
// babysit_verify_evidence.NEEDS_HUMAN.
// - risk (an entry ending in ":" is a prefix): babysit_risk.py's holding MED
// codes files-incomplete (:938) and evidence-unbound:<lane> (:978-998), and
// the manifest review (:598). Not the reader's `unreadable: …` score.
// files-incomplete and the manifest review join their items with "; ", so
// a reason with two or more items reads as unknown codes and stays.
// - verdict: babysit_verify_evidence.py's FAIL, EARLIER_FAIL and SPEC_CHANGED,
// which a push clears. Not untrusted-spec or repeated-fail (a person's),
// too-old (it recurs at the same head; #10249 makes it a person's), missing
// or unreadable (no answer), or ineligible (always paired with a link
// answer, which verifyHold reads first).
// - verdict: every babysit_verify_evidence.py refusal off NEEDS_HUMAN that
// answers: FAIL, EARLIER_FAIL and SPEC_CHANGED, which a push or a new
// judgment clears, and REPEATED_FAIL and TOO_OLD, which never clear but hold
// for the lanes with no label (owner rule 2026-10-27). Not untrusted-spec (a
// person's), missing or unreadable (no answer), or ineligible (always
// paired with a link answer, which verifyHold reads first).
// - link: none; an issue link is a person's.
export const LANE_CAUSES = Object.freeze({
link: Object.freeze([]),
verdict: Object.freeze(["verify:fail", "verify:earlier-fail", "verify:spec-changed"]),
verdict: Object.freeze(["verify:fail", "verify:earlier-fail", "verify:spec-changed", "verify:repeated-fail", "verify:too-old"]),
risk: Object.freeze(["files-incomplete", "evidence-unbound:", "manifest-review-required"]),
});

Expand Down
11 changes: 7 additions & 4 deletions .github/scripts/pr-merge/clear-escalations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ test("a hold marker from github-actions[bot] with no lane footer, or from anothe
const UNBOUND = "pr-merge's gate holds it on its risk score (evidence-unbound:claude-review: not bound: rerun-not-allowed)";
const SECURITY = "pr-merge's gate holds it on its risk score (security-finding: 1 open CRITICAL finding)";
const REPEATED = "pr-merge's gate refuses its verifier verdict (verify:repeated-fail)";
const TOO_OLD = "pr-merge's gate refuses its verifier verdict (verify:too-old)";
const UNTRUSTED = "pr-merge's lane does not take its issue link (untrusted-provenance)";
const UNTRUSTED_SPEC = "pr-merge's gate refuses its verifier verdict (verify:untrusted-spec)";
const COST = "pr-merge's gate holds it on its risk score (cost-review-required: infra/main.tf)";
Expand Down Expand Up @@ -553,6 +554,11 @@ test("enable-auto-merge's label comes off when the reader now reads a refusal th
[UNBOUND, (pull) => ({ ...clean(pull), reviews: "evidence-unbound:claude-review: not bound: rerun-allowed" })],
// A lane verdict now passes, and the risk score it hid names only lane causes.
[SPEC_CHANGED, reviewsNow("evidence-unbound:claude-review: not bound: rerun-allowed")],
// Owner rule 2026-10-27: the repeated-FAIL cap and an old pull request
// hold for the lanes, so their labels come off at the head where the
// reader still refuses them (#10134's shape).
[REPEATED, refusing("verify:repeated-fail")],
[TOO_OLD, refusing("verify:too-old")],
]) {
const w = world({ timeline: nativeEscalated(reason), author: BOT, evidence });
await run(w, NATIVE);
Expand All @@ -570,13 +576,10 @@ const PERSON_CLASS = [
risk("high-path:**: a symlink"),
risk("high-path:.github/workflows/**: .github/workflows/ci.yml"),
UNTRUSTED_SPEC,
REPEATED,
UNTRUSTED,
// A link reason, the reader's own unread link included.
"pr-merge's lane does not take its issue link (unreadable: timed out)",
// Off the lane list: a refusal that recurs at the same head, or one the
// reader always pairs with a link answer.
"pr-merge's gate refuses its verifier verdict (verify:too-old)",
// Off the lane list: one the reader always pairs with a link answer.
"pr-merge's gate refuses its verifier verdict (verify:ineligible)",
// A code not known to be a lane's, matched whole, or a person's beside a lane's.
risk("a-new-code: x"),
Expand Down
16 changes: 8 additions & 8 deletions .github/scripts/pr-merge/enable-auto-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,11 @@
// person's, and left alone while the reason and head are unchanged
// (refusalWrites). The
// comment is the record; only a cause a person must clear (owner rule
// 2026-10-26: an intent or design change or a CRITICAL security issue, with
// cost and trust holds and a hold no lane can clear kept: untrusted
// provenance or an untrusted spec, the repeated-FAIL cap, a pull request too
// old to verify, a security finding, a high path, a trusted-actor widening,
// a real charge or a listing too long to check, see verifyClears; a hold path, a configured denied path
// 2026-10-26 and 2026-10-27: an intent or design change or a CRITICAL
// security issue, with cost and trust holds kept: untrusted
// provenance or an untrusted spec, a security finding, a high path, a
// trusted-actor widening, a real charge or a listing too long to check, see
// verifyClears; a hold path, a configured denied path
// or the floor, which only a trusted approval lifts; and the lane's own hold
// line, owner decision Q-HOLDLINE) also gets the human-gated label, put back
// while the comment stands unless a person removed it since. Every other
Expand Down Expand Up @@ -508,9 +508,9 @@ export function verifyHold(evidence, head) {
// read whole, more than the 100 the read lists, which no later read changes.
export const TRUST_REFUSALS = Object.freeze(["untrusted-provenance", "untrusted-provenance-removed-by-non-human", "issue-labels-unreadable"]);

// Who clears the refusal verifyHold reads in `evidence` at `head` (owner rule
// 2026-10-26: a person only for an intent or design change, or a CRITICAL
// security issue; cost and trust holds and a hold no lane can clear stay a
// Who clears the refusal verifyHold reads in `evidence` at `head` (owner
// rules 2026-10-26 and 2026-10-27: a person only for an intent or design
// change, or a CRITICAL security issue; cost and trust holds stay a
// person's): `person`
// for an issue link the trust hold refuses (TRUST_REFUSALS), a verifier
// refusal the gate marks `needsHuman` (babysit_verify_evidence NEEDS_HUMAN)
Expand Down
31 changes: 14 additions & 17 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -370,29 +370,26 @@ test("a floor path no configured glob names is commented on and labeled human-ga
);
});

// Owner rule 2026-10-26: a person only for intent, design, a CRITICAL
// security issue or a hold no lane can clear. The gate's `needsHuman`
// (babysit_verify_evidence NEEDS_HUMAN: an untrusted spec, the terminal
// repeated-FAIL cap, a pull request too old to verify) decides, so a changed
// spec is a lane's.
test("only a verifier refusal the gate marks needsHuman is a person's; a changed spec is a lane's", async () => {
// Owner rules 2026-10-26 and 2026-10-27: a person only for intent, design or
// a CRITICAL security issue, trust holds kept. The gate's `needsHuman`
// (babysit_verify_evidence NEEDS_HUMAN: an untrusted spec) decides, so a
// changed spec, the repeated-FAIL cap and an old pull request are the lanes'.
test("only a verifier refusal the gate marks needsHuman is a person's; the cap and an old pull request are the lanes'", async () => {
const refused = (refusal, needsHuman) => ({ refusal, needsHuman, head: HEAD, ineligible: null, reviews: null });
const { notes } = await run([
pull(1, { verdict: refused("verify:untrusted-spec", true) }),
pull(2, { verdict: refused("verify:spec-changed", false) }),
pull(3, { verdict: refused("verify:repeated-fail", true) }),
pull(4, { verdict: refused("verify:too-old", true) }),
pull(3, { verdict: refused("verify:repeated-fail", false) }),
pull(4, { verdict: refused("verify:too-old", false) }),
pull(5, { verdict: { ...MISSING, refusal: "verify:ineligible", ineligible: "it closes 2 issues" } }),
]);
const refusing = (code) => `pr-merge's gate refuses its verifier verdict (${code})`;
assert.deepEqual(notes, [
{ method: "POST", path: `${ISSUES}/1/comments`, body: { body: personNote(refusing("verify:untrusted-spec")) } },
{ method: "POST", path: `${ISSUES}/1/labels`, body: { labels: ["needs-human"] } },
{ method: "POST", path: `${ISSUES}/2/comments`, body: { body: laneNote(refusing("verify:spec-changed")) } },
{ method: "POST", path: `${ISSUES}/3/comments`, body: { body: personNote(refusing("verify:repeated-fail")) } },
{ method: "POST", path: `${ISSUES}/3/labels`, body: { labels: ["needs-human"] } },
{ method: "POST", path: `${ISSUES}/4/comments`, body: { body: personNote(refusing("verify:too-old")) } },
{ method: "POST", path: `${ISSUES}/4/labels`, body: { labels: ["needs-human"] } },
{ method: "POST", path: `${ISSUES}/3/comments`, body: { body: laneNote(refusing("verify:repeated-fail")) } },
{ method: "POST", path: `${ISSUES}/4/comments`, body: { body: laneNote(refusing("verify:too-old")) } },
{ method: "POST", path: `${ISSUES}/5/comments`, body: { body: laneNote("pr-merge's lane does not take its issue link (it closes 2 issues)") } },
]);
});
Expand Down Expand Up @@ -1502,7 +1499,7 @@ test("no pass at the head, a FAIL or the repeated-FAIL cap arms nothing", async
const { code, writes, verified } = await run([
pull(1, { verdict: { refusal: "verify:missing", head: HEAD } }),
pull(2, { verdict: { refusal: "verify:fail", head: HEAD } }),
pull(3, { verdict: { refusal: "verify:repeated-fail", head: HEAD, needsHuman: true } }),
pull(3, { verdict: { refusal: "verify:repeated-fail", head: HEAD, needsHuman: false } }),
pull(4, { verdict: { refusal: "verify:earlier-fail", head: HEAD } }),
pull(5, { verdict: { refusal: null, head: "c".repeat(40) } }),
pull(6, { verdict: new Error("gh: rate limited") }),
Expand Down Expand Up @@ -2515,11 +2512,11 @@ test("a kept refusal on an armed pull request disarms it", async () => {
});

test("a kept refusal writes no label back; a changed reason is still written", async () => {
const reason = "pr-merge's gate refuses its verifier verdict (verify:repeated-fail)";
const repeated = { refusal: "verify:repeated-fail", needsHuman: true, head: HEAD, ineligible: null };
const reason = "pr-merge's gate refuses its verifier verdict (verify:untrusted-spec)";
const untrusted = { refusal: "verify:untrusted-spec", needsHuman: true, head: HEAD, ineligible: null };
const standing = (verdict) => pull(2, { verdict, discussion: { comments: [lanesSaid(personNote(reason))] } });
assert.deepEqual((await run([standing(repeated)])).notes, [{ method: "POST", path: `${ISSUES}/2/labels`, body: { labels: ["needs-human"] } }]);
const kept = await run([standing({ ...repeated, cached: true })]);
assert.deepEqual((await run([standing(untrusted)])).notes, [{ method: "POST", path: `${ISSUES}/2/labels`, body: { labels: ["needs-human"] } }]);
const kept = await run([standing({ ...untrusted, cached: true })]);
assert.deepEqual(kept.notes, []);
assert.equal(kept.calls.filter(({ body }) => body?.query === UNLABELED_QUERY).length, 0);
const moved = await run([pull(1, { verdict: CACHED_MISSING, discussion: { comments: [lanesSaid(laneNote("an earlier reason"))] } })]);
Expand Down
24 changes: 21 additions & 3 deletions .github/scripts/pr-merge/lane-causes-person-codes.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Expected values come from the invariant in clear-escalations.mjs's LANE_CAUSES
// comment: the causes the lanes lift are never ones only a person clears. The
// person set is read from babysit_risk.py (PERSON_CODES, PERSON_PREFIXES), the
// one list the merge gate reads.
// person sets are read from babysit_risk.py (PERSON_CODES, PERSON_PREFIXES) and
// babysit_verify_evidence.py (NEEDS_HUMAN), the lists the merge gate reads.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
Expand All @@ -10,7 +10,9 @@ import { fileURLToPath } from "node:url";
import { LANE_CAUSES } from "./clear-escalations.mjs";

const scripts = fileURLToPath(new URL("../../../plugins/source-control/skills/babysit-prs/scripts", import.meta.url));
const read = "import json,sys\nsys.path.insert(0, sys.argv[1])\nimport babysit_risk as b\nprint(json.dumps({'codes': sorted(b.PERSON_CODES), 'prefixes': list(b.PERSON_PREFIXES)}))";
const read =
"import json,sys\nsys.path.insert(0, sys.argv[1])\nimport babysit_risk as b\nimport babysit_verify_evidence as v\n" +
"print(json.dumps({'codes': sorted(b.PERSON_CODES), 'prefixes': list(b.PERSON_PREFIXES), 'verdicts': list(v.NEEDS_HUMAN)}))";

function personSets() {
const python = spawnSync("python3", ["-I", "-c", read, scripts], { encoding: "utf8" });
Expand Down Expand Up @@ -39,3 +41,19 @@ test("no LANE_CAUSES.risk entry is a reason only a person clears", () => {
}
}
});

test("no LANE_CAUSES.verdict entry is a verifier refusal only a person clears", () => {
const { verdicts } = personSets();
// Owner rule 2026-10-27: an untrusted spec is a trust boundary, a person's.
assert.ok(verdicts.includes("verify:untrusted-spec"), "NEEDS_HUMAN lost verify:untrusted-spec");
const overlapping = LANE_CAUSES.verdict.filter((entry) => verdicts.includes(entry));
assert.deepEqual(overlapping, [], `lane verdicts ${overlapping} are in NEEDS_HUMAN`);
});

test("the repeated-FAIL cap and an old pull request are lane causes the clear lifts", () => {
// Owner rule 2026-10-27: neither is intent, design or security, so a label
// either one placed comes off at its head.
for (const refusal of ["verify:repeated-fail", "verify:too-old"]) {
assert.ok(LANE_CAUSES.verdict.includes(refusal), `${refusal} is not a lane cause`);
}
});
7 changes: 4 additions & 3 deletions docs/conventions/pr-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -955,9 +955,10 @@ merge:
skip-unchanged:
max-minutes: 60
max-skips: 3
# pr-verify FAILs at this many distinct heads of one PR hold it for a human
# whatever a later pass says, so pushing until the verifier passes once
# does not merge it (owner decision 2026-10-21).
# pr-verify FAILs at this many distinct heads of one PR hold it, never to
# merge unattended, whatever a later pass says, so pushing until the
# verifier passes once does not merge it (owner decision 2026-10-21). The
# hold is the lanes', with no label (owner rule 2026-10-27).
max-verify-fails: 3
# Days a suite failing on main outside the merges' affected set stays
# quarantined: its quarantine issue excuses its failures from demotion
Expand Down
Loading
Loading