Skip to content

fix(pipeline): a banked §CP PR with no ticking approval-watcher now reds (#4754) - #4983

Merged
usirin merged 1 commit into
mainfrom
usirin/cp-bank-arming-guard-4754-81183512
Aug 9, 2026
Merged

fix(pipeline): a banked §CP PR with no ticking approval-watcher now reds (#4754)#4983
usirin merged 1 commit into
mainfrom
usirin/cp-bank-arming-guard-4754-81183512

Conversation

@usirin

@usirin usirin commented Aug 9, 2026

Copy link
Copy Markdown
Member

Fixes #4754

The shape taken, and why

The issue offers two shapes. This PR takes the second — make the absence of a tick reportable — and it does not take the first.

Arming is not an act; it is a loop. Banking writes board state once, but the watcher has to keep ticking, and no single act at bank time can guarantee that a loop is still running an hour later. A "banking also writes an armed record" fix would produce exactly the defect it was meant to close, one level up: an armed record with no ticks after it is again indistinguishable from an armed-and-quiet watcher. So the coupling has to live on the read side.

What is now true: banking is one verb that always writes the label the watch set is derived from, and a non-empty banked set with no ledger tick inside a bounded window is a RED that fires on a schedule, with no human running anything by hand.

The new tool — pipeline-cli cp-bank

cp-bank apply --pr N --approver LOGIN   bank a §CP PR as ONE act, proven by read-back
cp-bank set                             the board-derived banked watch set, as JSON
cp-bank check [--window-hours H]        RED when banked work has no ticking watcher
  • apply provisions the status:cp-banked label if the repo lacks it, applies it, assigns the approver, requests their review, then re-reads the PR and fails if the label or assignment did not land. Four 2xx responses are not evidence the board carries the state; the read-back is.
  • set is the shipped implementation of the derivation the engine definition called by name only. banked_cp_prs_awaiting_approval_json appeared exactly once repo-wide, at its call site, with no definition anywhere — each engine session was expected to invent it. It now prints the open PRs carrying the banked label as a JSON array, in the exact shape the existing tick frame already consumes (all_pages_are_arrays, then jq -r '.[].number').
  • check correlates that board-derived set against the approval-watcher's tick ledger and reds when the set is non-empty and the newest tick is missing or older than the window (default 2h).

The reader (AC 3)

.github/workflows/cp-bank-guard.yml runs cp-bank check hourly. This is a schedule, not a pull_request gate, because the failure has nothing to do with any diff — it is a state of the board that appears between PRs.

cp-bank is deliberately a different tool from approval-watcher. The watcher writes the ledger, and a surface that only ever writes its own trace can never notice that it stopped writing. The ledger read itself is not re-derived here: approval-watcher grew a liveness method (the newest tick instant across the ledger, taken as a max rather than off the last comment, because coalescing patches an older comment and bumps its lastAt), and cp-bank consumes that. That read resolves the ledger without provisioning one — a guard must not mint board state, so findLedger was split out of resolveLedger and only the write path still creates.

Nothing in the §CP discharge predicate was touched. cp-cardinality remains the single source of the approve-then-enqueue decision; this PR is about whether the loop that consults it is running at all.

The three-state contract, and why the third state is the point

check exits 0 = proven GREEN, 1 = proven RED, 2 = UNKNOWN.

The defect class here is "a check that cannot see what it is looking for returns a plausible value instead of an error." A repo whose label set lacks status:cp-banked would derive an empty banked set and pass — vacuously, forever, exactly the shape #4272 fixed for the triage guards. So the scoping label's presence is proven first, against the live label universe (RepoLabels.presence, the existing seam), and its absence resolves UNKNOWN, never "nothing is banked". An unparseable tick instant, an unparseable clock, and a non-positive window resolve UNKNOWN too. 1 and 2 both fail the job; keeping them apart is what makes the report say which happened.

Adoption is asserted rather than assumed: status:cp-banked is added to the vocabulary-preflight required label set, so a repo that adopts the pipeline without the label reds at the preflight instead of silently getting a guard that can never fire. The label has been created on this repo.

Live evidence, run against the real board

Before the label existed:

cp-bank: UNKNOWN — the scoping label(s) status:cp-banked do not exist in this repo, so the banked
set could not be derived — this is UNKNOWN, NOT an empty board. …
EXIT=2

After creating it:

cp-bank: GREEN — no open PR carries `status:cp-banked` — nothing is banked, so nothing is owed a tick
EXIT=0

vocabulary-preflight checkprerequisites met — all 17 required labels exist.

The regression exercise (AC 4)

packages/pipeline-cli/src/tools/cp-bank/cp-bank.unit.test.ts pins the identity finding 1 established, and asserts it is broken. The same board — one banked §CP PR — judged twice:

ledger state verdict
a tick 10 minutes ago (bank with arm) GREEN
no tick record at all (bank without arm) RED, naming the stranded PR

with an explicit assertion that the two verdicts differ. The staleness cases replay the incident's own numbers (a tick 8.6h stale reds); the fail-closed cases cover the absent label, the unreadable instants, and the non-positive window. 13 tests; the full pipeline-cli suite is 3031 passing.

Explicitly out of scope (AC 5)

Nothing here improves a watcher that is already ticking. check keys on tick recency only — not coverage, cadence jitter, or disposition fidelity. Set membership follows from the derivation being board-sourced: any engine ticking against the same board derives every banked PR, so recency is the right signal for "is anything watching at all". Tick quality once armed is #4790, which the founder deliberately left in milestone #38.

§CP status

This PR is control-plane, confirmed with pipeline-cli cp-classify classifycontrol-plane [path-match] (packages/pipeline-cli/src/registry.ts, plus .github/workflows/). It needs a control-plane approval at its current head. The triage note anticipated this: the reader has to live where it can run on a schedule, and that is inside §CP.

…eds (#4754)

Banking a control-plane PR and arming the approval-watcher were two independent
acts, and the board state after a bank-without-arm was identical to a
bank-with-arm. PR #4742 sat approved-and-unenqueued for 8h34m and emitted
nothing; a human on a routine pass caught it. Two breaks were stacked: the loop
was never armed, and the watch set it was told to re-derive had no shipped
implementation anywhere in the tree.

The new `cp-bank` tool closes both:

- `cp-bank apply` is the whole banking act as one verb — provision + apply the
  `status:cp-banked` label, assign the approver, request their review — then
  read the PR back and fail if it did not land. Banking always writes the label
  the derivation keys on.
- `cp-bank set` is the shipped board derivation of the banked watch set,
  replacing `banked_cp_prs_awaiting_approval_json`, a helper name with no
  definition.
- `cp-bank check` is the outside reader: it correlates that board set against
  the approval-watcher's tick ledger and reds when banked work exists with no
  tick inside a bounded window. `.github/workflows/cp-bank-guard.yml` runs it
  hourly, so an unarmed watcher surfaces without a human running `ticks`.

Arming is a loop, so banking cannot *be* the arming — a one-shot armed flag
would move the same silence one level up. The coupling is therefore on the read
side: the absence of a tick is what reds.

`check` is three-state (0 green / 1 RED / 2 UNKNOWN). A repo whose label set
lacks `status:cp-banked` derives an empty board, so that state is UNKNOWN and
never a vacuous pass (ADR 0092); the label is added to the vocabulary-preflight
required set so adoption is asserted rather than assumed. The ledger read is
`approval-watcher`'s own new `liveness`, resolved without provisioning — a
guard must not mint board state.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

No preview deploy

  • No preview deploy for this PR — its diff touches no deploy-relevant path, so no preview stack was minted and e2e is not applicable. (d955121)
  • web — Stage pr-4983 torn down.

@usirin

usirin commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

review-code: advisory — §CP blocking-set verdict, no bindable first-line @ <sha> by design (ADR 0111/0151).

Reviewed-head: @ d955121

pipeline-cli cp-classify classify (live CONTROL_PLANE_RE, re-resolved) → control-plane [path-match] on .github/workflows/cp-bank-guard.yml (and packages/pipeline-cli/src/registry.ts). BLOCKING — human merge. The builder's §CP call is confirmed.

Required namespaces, from pipeline-cli class-probe classify --namespaces over the 11 changed files: review-code, review-skill (has-code, has-skills). No apps/web/src/ path, so review-design is not required and is not dispatched. Both required namespaces are covered at this head.

Acceptance criteria — #4754

# Criterion Verdict
1 Bank and arm can no longer be performed independently — one act, or a bounded-window red; shape stated [PASS]
2 Watch set derivable from the board by a shipped, single-sourced implementation; the label exists and banking applies it [PASS]
3 A reader other than approval-watcher itself consumes the ledger, no human running ticks by hand [PASS]
4 A regression exercise shows bank-without-arm now differs observably from bank-with-arm [PASS]
5 Not closed by anything that only improves an already-ticking watcher [PASS]

AC 1. Shape (b), stated in the PR body and in the EM definition: arming is a loop, so no one-shot act can guarantee it is still running — the coupling lives on the read side. The reasoning is right, and it is the reasoning the issue's own framing invites. What banking can be made atomic is the label the derivation keys on, and it is (cp-bank apply, proven by read-back rather than by four 2xx responses; BankNotLanded on a failed read-back).

AC 2. banked_cp_prs_awaiting_approval_json — the helper name with no definition anywhere in the tree (finding 3) — is replaced at its one call site by "$PCLI" cp-bank set. Verified live that the shipped derivation's exact query resolves real board state: status:cp-banked exists on this repo with the color/description createLabelArgs produces, and state=all returns #4985 and #4973 — banked, now closed. The open set is legitimately empty right now, which is a derived empty, not an unread one.

AC 3. .github/workflows/cp-bank-guard.yml, hourly at :41, pipeline-cli cp-bank check. It is a distinct tool from approval-watcher on the stated reasoning that a surface which only writes its own trace cannot notice it stopped writing — and the ledger's shape is still owned once, by approval-watcher.liveness, not re-derived here.

AC 4. cp-bank.unit.test.ts judges one identical board twice and asserts the verdicts differ (_tag and rendered report), plus that the RED names the stranded PR. The staleness case replays the incident's own 8.6h.

AC 5. check keys on tick recency only. The approval-watcher edit is a read addition (liveness) plus a refactor split; resolveLedger's behavior is preserved exactly (explicit → env → label search → provision), with the read path stopping before provisioning so a guard cannot mint board state.

The load-bearing claim, attacked

The claim under test: an empty watch set can never resolve GREEN unless it was proven empty. I enumerated every path by which the set could come back empty, unreadable, short-read, or non-conforming.

Path Resolves to
status:cp-banked absent from the repo label universe UNKNOWN (2) — judge tests vocabulary first, before it will read banked.length === 0
Label universe read fails (auth, rate-limit, 404) typed error → non-zero; never GREEN
Label universe reads as empty UNKNOWNresolvePresence treats an empty universe as absent by construction
Banked-set read fails (gh non-zero) typed error → non-zero
Banked-set body is an error object, not pages of arrays SchemaError on Array(Array(RawIssue)) → non-zero. A {"message":"Not Found"} cannot flatten to []
Short/partial pagination --paginate --slurp exits non-zero → typed error
Ledger does not exist no-ledgerno-recordRED with a non-empty set (the #4742 case exactly)
Ledger comments unreadable typed error → non-zero
Unparseable tick instant / unparseable now / non-positive window UNKNOWN (2), each with its own test
Presence and derivation resolving different repos not constructible — both vocabulary-preflight/github.ts and tracker/gh-io.ts use the identical CLAUDE_PIPELINE_REPOGITHUB_REPOSITORYgh repo view order

I could not construct a vacuous pass. The single GREEN-with-an-empty-set branch is reachable only after the scoping label has been proven present against the live universe, and every unreadable input on the way there lands on 2 or on a hard error. Both non-zero states fail the job, and the report says which. The claim holds.

Two observations, neither blocking and neither a counterexample:

  • An infra error and a RED share exit 1. CheckFailed exits 1 via gate-fail.ts; an unhandled BankBoardError/RepoLabelsError escapes checkArming and takes the CLI's default non-zero, which is also 1. The command.ts docblock says "any other non-zero is a run that never happened," and by exit code alone that is not distinguishable from a RED. It fails closed either way and the stderr text differs, so this is fidelity, not safety — but it is the one place the three-state discipline the tool argues for is not fully realized.
  • A future-dated lastAt reads as fresh. age goes negative, age > windowHours is false, verdict GREEN. Requires a corrupt record or a writer clock ahead of the runner's; the tool writes ISO-now, so I can't reach it from a shipped path.

cp-cardinality is still the single source

grep -E "cardinality|APPROVED|reviews|discharge" across packages/pipeline-cli/src/tools/cp-bank/ returns nothing. The new tool never evaluates the approve-then-enqueue decision — it reads board membership and tick recency and nothing else. No second copy was forked.

Un-armed vs armed-and-quiet

Genuinely distinguishable, and for the right reason: it does not rest on a flag banking writes (which would reproduce the defect one level up, as the PR argues). It rests on the ledger's own no-record/empty-record distinction — approval-watcher record auto-provisions the ledger, so "no ledger" and "ledger with no tick" are both a proven absence, which is what liveness encodes and judge reads as RED against a non-empty set. An armed-and-quiet watcher writes an empty-set tick each cycle and stays GREEN. That asymmetry is the fix.

liveness taking max(lastAt) rather than the last comment's is correct and load-bearing: coalescing patches an older comment, so comment order is not recency order.

CI

43 success / 3 skipped / 0 failure / 0 pending at d955121.

@usirin

usirin commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

review-skill: advisory — §CP blocking-set verdict, no bindable first-line @ <sha> by design (ADR 0111/0151).

Reviewed-head: @ d955121

Scope: claude-plugins/pipeline-crew/agents/crew-engineering-manager.md (+28 −10). §CP confirmed by cp-classify classifycontrol-plane [path-match]. Required namespaces (class-probe classify --namespaces): review-code, review-skill — both covered at this head.

The outstanding question — does any other shipped path bank a §CP PR without writing the label?

This is the one that decides whether the fix is real, because a bank that skips the label produces an empty derived set, a GREEN guard, and a stranded PR: the original defect wearing a new coat. Answered directly, not by intent-reading:

Code. grep -E "requested_reviewers|/assignees|--add-assignee|add-reviewer" across claude-plugins/, packages/, .github/ at this head. The only POST .../pulls/{n}/requested_reviewers in the tree is cp-bank/github.ts:79. The other /assignees writers are triage/scripts/claim-issue.sh, release-claim.sh, and claim/github.ts — issue claiming, not §CP banking, and none of them requests a review. There is exactly one shipped implementation of the bank act, and it writes the label.

Instructions. grep -i bank over claude-plugins/kampus-pipeline/ returns three hits, all descriptive prose (reviewer.md, gh-issue-intake-formats.md, gate-verdict-contract.md). ship-it does not bank; it refuses to merge §CP. The engineering-manager definition is the only agent that performs a bank, and it now routes through cp-bank apply with an explicit "never a hand-rolled trio of gh api calls."

One residual, non-blocking. Three files still describe banking generically as "assigns it to the approver and labels it banked" without naming the verb or the label: pipeline-crew/HOW-TO.md:123-125, agents/crew-chief-of-staff.md:121, PERSONALIZATION.md:67. None of them instructs anyone to perform a bank — the chief-of-staff is explicitly forbidden from banking and only carries banked PRs out — so none is a second path that can produce an unlabelled bank. They are stale descriptions, worth a follow-up sweep, not a defect in this PR. The genuinely uncovered case is a human banking by hand outside the tool; AC 2 scopes the shipped act, and the founder's two banks tonight (#4985, #4973) carried the label.

The bash the definition ships

BANKED_JSON="$("$PCLI" cp-bank set)" replacing the undefined banked_cp_prs_awaiting_approval_json is the load-bearing edit, and I checked it end to end rather than reading it for intent:

  • cp-bank set prints the JSON array on stdout and its human note on stderr, so the command substitution captures only the payload.
  • The payload is a flat array, but it is piped into all_pages_are_arrays, which was written for --paginate page streams. jq -e -s 'length > 0 and all(.[]; type == "array")' slurps [] into [[]] → length 1, element is an array → true. A flat array passes. No false "unreadable payload", and the watch loop is not silently disarmed by its own guard.
  • Empty stdout with exit 0 slurps to [] → length 0 → false → the --watch-unresolved "board: unreadable payload" branch. Fail-closed, correctly.
  • A non-zero cp-bank set short-circuits the && into the same branch.
  • jq -r '.[].number' reads the flat array correctly.

"$PCLI" is bound by literal assignment, quoted at every use; no bare pipeline-cli invocation (CI's "no bare pipeline-cli invocation in runnable shell" is green). No GraphQL, no bare git push, no home/local/absolute/sibling paths introduced. .github/workflows/cp-bank-guard.yml is a real resolvable path.

Prose hygiene

The new "arming is a loop, so banking cannot be the arming" bullet states the shape, the reader, the schedule, and the operational consequence for the engine ("if you are holding banked §CP PRs, your loop ticking is what keeps that guard green") — a behavioral instruction, not a restatement of the tool's docblock. It does not re-derive the tool-owned decision, which is what the crew-corpus lint checks; that job is green.

Governing rulings on both threads — read, and honored

Disclosed out-of-diff write — creating status:cp-banked on the live repo

Properly disclosed, in three places: the PR body ("The label has been created on this repo"), the progress comment's Gotchas, and implicitly by the vocabulary-preflight addition. More importantly, the PR is coherent for a reviewer who cannot see that write, which is the harder bar:

  • cp-bank apply provisions the label itself, so a foreign install needs no manual step.
  • judge returns UNKNOWN, not GREEN, when the label is absent — so a repo without the write gets an unanswered question, never a vacuous pass.
  • vocabulary-preflight now requires it, so its absence reds at the preflight rather than silently.

The live write is a convenience that makes the guard useful today; correctness does not depend on it. I verified the live label carries exactly the color (5319e7) and description createLabelArgs produces, so what exists on the repo matches what the code would have created. That is the right relationship between an out-of-diff write and its diff.

@usirin usirin added ready-for:human A human picks this up. status:cp-banked Control-plane PR banked on the board, awaiting a control-plane approval labels Aug 9, 2026
@usirin

usirin commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

BANKED — awaiting @kamp-us/control-plane approval at head d955121d

Both required namespaces carry a current-head verdict. §CP (cp-classifycontrol-plane [path-match] on .github/workflows/cp-bank-guard.yml and registry.ts), so the engine does not merge: a control-plane approval at this head lets ship-it enqueue (ADR 0135). No hand-merge.

namespace verdict binds
review-code advisory PASS (§CP) d955121d
review-skill advisory PASS (§CP) d955121d

Required set {review-code, review-skill} derived via class-probe over 11 files. No apps/web/src/ path, so review-design correctly not required. Head re-verified unmoved immediately before banking. CI at head: 43 success, 3 skipped, 0 failure, 0 pending.

What this fixes. Issue #4754: banking a §CP PR did not arm the approval-watcher, so an approved-but-unnoticed §CP PR could stall forever — the human does their part and nothing tells the engine. The build took the issue's second option on the reasoning that arming is a loop, not an act: a "banking writes an armed record" fix reproduces the same defect one level up, since an armed record with no ticks after it is again indistinguishable from armed-and-quiet. So banking writes the label the watch set derives from, and the absence of a tick is what reds.

The load-bearing claim was attacked, not read for intent. The claim is that check is three-state — 0 GREEN / 1 RED / 2 UNKNOWN — so an unreadable or empty watch set can never pass vacuously. The gate enumerated ten paths to an empty / unreadable / short / non-conforming set; every one lands on UNKNOWN or a hard error, none on GREEN. The decisive details: judge tests label presence before it will read banked.length === 0; resolvePresence treats an empty label universe as absent by construction; and the banked-set decoder is Array(Array(RawIssue)), so a {"message":"Not Found"} error body cannot flatten to []. No vacuous-pass counterexample was constructible.

The question a previous stalled gate died before answering, now answered: does any other shipped path bank a §CP PR without writing the label? No. cp-bank/github.ts:79 is the only POST .../requested_reviewers in the tree and it writes the label. ship-it does not bank; the chief-of-staff is forbidden from banking. Three files still describe banking generically (claude-plugins/pipeline-crew/HOW-TO.md:123-125, the chief-of-staff agent def, and the personalization doc) — stale prose with no instruction to perform a bank, so not a second path. Non-blocking.

cp-cardinality was not forked — zero cardinality/approval/discharge references under cp-bank/. The §CP discharge stays single-source.

Two non-blocking observations recorded by the gate: an infra error and a RED share exit 1 (a fidelity limit, not a safety one — both fail the job); and a future-dated lastAt would read as fresh, unreachable from any shipped path.

Out-of-diff action, disclosed and assessed: the build created the status:cp-banked label on the live repo, outside the diff. The gate judged it properly disclosed and the PR coherent without it — correctness does not depend on the write (an absent label yields UNKNOWN, apply provisions it, vocabulary-preflight requires it), and the live label matches createLabelArgs exactly. For the approver's awareness: that label is already in use — three §CP PRs were banked with it tonight.

An approval binds the head it is given at (ADR 0058). Any push after approval invalidates it and re-banks the PR.

@usirin
usirin added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 724eea7 Aug 9, 2026
46 checks passed
@usirin
usirin deleted the usirin/cp-bank-arming-guard-4754-81183512 branch August 9, 2026 02:54
@github-actions github-actions Bot mentioned this pull request Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for:human A human picks this up. status:cp-banked Control-plane PR banked on the board, awaiting a control-plane approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

banking a §CP PR does not arm the approval-watcher — an approved PR can strand indefinitely with no error

3 participants