Overview
Phase 4 is froot's thesis: stop proposing and start acting — but only where a class of work has earned it, on a record that triangulates so no single gamed metric can buy autonomy. The loop now auto-merges a clean (judge) + green (CI) bump when its (repo, loop) class has earned the grant on an allowlisted repo and an independent deep review approves.
The allowlist is empty by default, so every un-opted repo behaves exactly as before — the same verdict is the advisory shadow gate a steward watches. Authority rests on four bearings that fail differently and can't all lie at once (§3.7–3.8): the approval rate, the post-merge defect rate, an adversarial gate self-test, and a deep review at the gate. This guide walks the pure-core extension first, then each bearing, then the honesty sweep.
The pure core, extended
The acting gate is added the way Phase 1's close-on-red was: as data in the pure state machine, not I/O sprinkled through the spine. Three new states, three new effects, two new events. A bump that auto-merges flows AwaitingCi → GateReviewing → Merging → Recorded, mirroring the AwaitingCi → Closing → Recorded red path.
src/froot/domain/state.py · 105 lines
⋯ 59 lines hidden (lines 1–59)
⋯ 19 lines hidden (lines 87–105)
src/froot/domain/effects.py · 109 lines
⋯ 59 lines hidden (lines 1–59)
⋯ 17 lines hidden (lines 93–109)
ReviewBump carries the candidate (to re-read its changelog) and the PR; MergePullRequest carries only the PR. GateReviewed carries a ChangelogVerdict — clean approves, anything else holds.
src/froot/domain/events.py · 83 lines
⋯ 48 lines hidden (lines 1–48)
The acting flip — where the machine decides to merge
In AwaitingCi, a terminal green CI plus a clean changelog plus the class-level grant now routes to the deep review rather than straight to record. The per-PR conditions (green + clean) are checked here in the pure machine; the class-level grant (auto_merge_eligible) is decided outside it and passed in, exactly like close_on_red — so the machine stays pure and replay-safe.
src/froot/policy/state_machine.py · 285 lines
⋯ 178 lines hidden (lines 1–178)
⋯ 60 lines hidden (lines 226–285)
GateReviewing then forks on the review's verdict: clean → Merging + MergePullRequest; anything else → Recorded, leaving the green PR open for the human. Fail-closed: an unreviewable bump arrives non-clean and never merges.
src/froot/policy/state_machine.py · 285 lines
⋯ 226 lines hidden (lines 1–226)
⋯ 37 lines hidden (lines 249–285)
The class-level grant — computed once, outside the machine
The grant is about the class's history, not this one PR, so the workflow decides it once up front and threads it into advance(). The activity short-circuits to False for any non-allowlisted repo — the common case costs nothing and touches no network — and otherwise re-derives standing from live GitHub via the dashboard's own logic.
src/froot/workflow/activities.py · 636 lines
⋯ 356 lines hidden (lines 1–356)
⋯ 241 lines hidden (lines 396–636)
auto_merge_eligible reuses read_model.earned_now — the exact windowed, environment-scoped triangulation the dashboard panel shows — so the acting gate and the advisory panel can never disagree.
src/froot/dashboard/read_model.py · 703 lines
⋯ 410 lines hidden (lines 1–410)
⋯ 275 lines hidden (lines 429–703)
src/froot/workflow/bump_workflow.py · 213 lines
⋯ 79 lines hidden (lines 1–79)
⋯ 80 lines hidden (lines 87–166)
⋯ 28 lines hidden (lines 186–213)
Bearings 1 & 2 — rate and post-merge defect rate
class_earned is the gate's arithmetic: it triangulates two independent bearings. The approval rate over enough decided PRs (track record), and the post-merge defect rate over enough confirmed-held merges (did reality punish the merge). They fail differently — rate to rubber-stamping, defect rate to a weak oracle — so a class earns only when both clear with evidence behind each. Checks run cheapest-first; the first to fail is the blocker to fix.
src/froot/policy/autonomy.py · 168 lines
⋯ 83 lines hidden (lines 1–83)
⋯ 45 lines hidden (lines 124–168)
The outcome detector — did the merge hold? (P4.1)
The defect bearing needs an oracle for what happened after a merge. fetch_outcomes reads the default branch's recent commits to match froot's (#N) squash tail to its merge commit and spot Revert commits, then reads each merge commit's check-runs. Deliberately coarse and low-recall: a manual or bundled revert is invisible, so the defect rate is a floor, not the truth — the adversarial leg is what exercises the gap.
src/froot/dashboard/github_source.py · 355 lines
⋯ 189 lines hidden (lines 1–189)
⋯ 32 lines hidden (lines 208–239)
⋯ 44 lines hidden (lines 312–355)
Bearing 3 — the adversarial gate self-test (P4.2)
The rate and defect bearings need volume to mean anything; froot's per-class volume is tiny. A probe needs none — it is informative at N=1. gate_probe is the §2.11 deliberate disturbance aimed at the gate's new risk surface: a battery of synthetic class histories a healthy gate MUST refuse, each engineered to fail a different threshold, scored against the live policy. Any it would grant is an escape — the alarm. It catches a gate loosened by config drift (§3.7 silent drift) the moment it happens.
src/froot/policy/gate_probe.py · 111 lines
The gate_selftest activity runs the battery against the live AutonomySettings every scan tick, logging healthy at INFO (a heartbeat) and any escape at ERROR (surfaces in ClickStack). Repo-independent and cheap.
src/froot/workflow/activities.py · 636 lines
⋯ 511 lines hidden (lines 1–511)
⋯ 95 lines hidden (lines 542–636)
src/froot/workflow/scan_workflow.py · 95 lines
⋯ 59 lines hidden (lines 1–59)
Bearing 4 — the independent deep review at the gate (P4.4)
The fourth leg (§3.7's sampled deep review, here run on every auto-merge candidate). The first changelog judge frames risk neutrally and never gates; this one is the opposite — an independent, adversarial re-read whose approval is required to merge. Its system prompt makes the stance asymmetric: the burden is on the changelog to prove safety.
src/froot/adapters/model_judge.py · 166 lines
⋯ 50 lines hidden (lines 1–50)
⋯ 60 lines hidden (lines 75–134)
The gate_review activity fetches the changelog and runs the second pass, fail-CLOSED throughout: a missing changelog or a model error returns a non-clean verdict, so an unreviewable bump never merges unattended (the opposite disposition from judge_changelog, which degrades-to-proceed).
src/froot/workflow/activities.py · 636 lines
⋯ 200 lines hidden (lines 1–200)
⋯ 389 lines hidden (lines 248–636)
The environment fingerprint — trust resets on a model swap (P4.3b)
Trust is conditional on the environment that earned it (§3.7). Every recorded PR is stamped with a froot-env:<model> label; the dashboard and the gate count only PRs from the current environment, so a judge-model swap resets each class's record rather than letting trust silently ride across a capability change.
src/froot/policy/environment.py · 56 lines
Honest framing & verification
The acting flip made the gate load-bearing, so the prose that called it a dry run that 'acts on nothing' became the most dangerous kind of stale doc — it understates the authority a steward is granting. The dashboard footer now states the conditional merge authority plainly and the class-gate note names all four legs.
src/froot/dashboard/render.py · 883 lines
⋯ 708 lines hidden (lines 1–708)
⋯ 157 lines hidden (lines 727–883)
Full suite green: 351 tests, mypy clean, ruff format + lint clean, both determinism gates green (no hazard reaches a workflow). New discriminating tests cover the acting flip (state machine + workflow + e2e), the gate self-test (escapes under loosened config), the deep-review veto (eligible but held → not merged), and fail-closed degradation.