What changed, and why
froot opens dependency- and security-patch PRs and a human approves every merge. It has been record-only: the dashboard reports the track record but draws no conclusion from it. Phase 3 makes that record legible as trust.
For each (repo, loop) class it now derives whether the class has earned a hypothetical auto-merge gate move, and for each open PR whether it would auto-merge under that grant. This is the shadow gate — a dry run a steward watches before any authority is granted. Nothing acts. Phase 4 will flip the same pure functions to action; this PR only computes and renders the verdict.
The change is grounded in the Many Hands Engineering (MHE) trust economy. Read it in four beats: the pure verdict (policy/autonomy.py), how the read-model derives each class's standing, what the page shows, and a separate run-ledger instrumentation beat that makes the scan stage legible.
The pure verdict — policy/autonomy.py
The whole decision lives in one pure, dependency-free module so Phase 4 can reuse it verbatim. class_earned answers "has this class earned its gate?" from a track record; pr_autonomy answers "would this PR ride the grant?".
The module docstring names MHE's five properties the grant carries — earned, narrow, conditional, revocable, expiring — and is blunt about the caveat that keeps this advisory: the approval rate "is the headline, not the whole truth".
The five properties, then class_earned: enough decided PRs at a high enough rate, measured over a recent window.
src/froot/policy/autonomy.py · 125 lines
⋯ 10 lines hidden (lines 1–10)
⋯ 44 lines hidden (lines 21–64)
⋯ 41 lines hidden (lines 85–125)
pr_autonomy: the substantive blockers first, the steward's own allowlist switch last.
src/froot/policy/autonomy.py · 125 lines
⋯ 86 lines hidden (lines 1–86)
Deriving each class's standing — read_model.py
The read-model joins GitHub outcomes to Temporal runs and now folds in the earned-autonomy figures. Two small helpers window the record to recent decisions (trust is recent, not lifetime): _decided_at dates a PR by its merge instant, falling back to its open time because a BumpRow carries no close timestamp; _within is the inclusive look-back test.
The windowing helpers — the closed-PR proxy is noted in the docstring, not hidden.
src/froot/dashboard/read_model.py · 587 lines
⋯ 250 lines hidden (lines 1–250)
⋯ 320 lines hidden (lines 268–587)
_class_gates builds one ClassGate per configured (repo, loop): the windowed decided/merged counts, the merge rate, the earned verdict, and a steward-time budget — approvals/week the class costs now, and the clean-and-green slice a gate move would reclaim.
Note the spotlight: reclaim is zeroed unless the class is earned.
src/froot/dashboard/read_model.py · 587 lines
⋯ 268 lines hidden (lines 1–268)
⋯ 260 lines hidden (lines 328–587)
_gate annotates each open PR with the shadow verdict via pr_autonomy + an immutable model_copy.
src/froot/dashboard/read_model.py · 587 lines
⋯ 329 lines hidden (lines 1–329)
⋯ 217 lines hidden (lines 371–587)
What the page shows — model.py + render.py
The view model gains a frozen ClassGate, and BumpRow gains would_auto_merge / held_reason (plus a loop label so the two loops' records never mix). Every figure is computed in the read-model, so the renderer stays a dumb projection.
ClassGate — the per-class standing, fully derived.
src/froot/dashboard/model.py · 357 lines
⋯ 101 lines hidden (lines 1–101)
⋯ 223 lines hidden (lines 135–357)
The renderer adds an "Earned autonomy · the shadow gate" panel and a per-PR badge in the approval queue. The badge reads would auto-merge or held · <reason>; both the panel note and the gate note are scrupulous that nothing acts and that a merge-rate record is not a confirmed-good (or security-reviewed) outcome.
The panel: a class row table plus a folded MHE framing note.
src/froot/dashboard/render.py · 763 lines
⋯ 274 lines hidden (lines 1–274)
⋯ 444 lines hidden (lines 320–763)
The shadow-gate badge — advisory; the green case means the human approval is the only remaining step.
src/froot/dashboard/render.py · 763 lines
⋯ 377 lines hidden (lines 1–377)
⋯ 374 lines hidden (lines 390–763)
Config and wiring — settings.py + server.py
AutonomySettings (FROOT_AUTOMERGE_*) tunes the thresholds and the allowlist, and builds the pure AutonomyPolicy the read-model consumes. The allowlist is a comma-separated list, empty by default — the revocable switch, off until a steward flips it. server.py reads it per request and degrades to conservative defaults so a bad value can never blank the page.
Settings at the edge; the pure policy is built in .policy().
src/froot/config/settings.py · 332 lines
⋯ 286 lines hidden (lines 1–286)
The per-request policy, with a safe fallback.
src/froot/dashboard/server.py · 201 lines
⋯ 65 lines hidden (lines 1–65)
⋯ 119 lines hidden (lines 83–201)
Run-ledger instrumentation — telemetry.py + activities.py
A second, independent beat: make the signal stage legible. scan_candidates now reports its selectivity — how much upstream signal it considered versus how much it kept — so a tick that proposes nothing is still visible in the ledger.
set_span_attributes is a thin helper that writes froot-namespaced attributes onto the activity's span. It lazy-imports OpenTelemetry and no-ops when FROOT_OTEL is off, so nothing enters the Temporal workflow sandbox and local runs pay nothing.
No-op early-return, then a lazy otel import.
src/froot/adapters/telemetry.py · 156 lines
⋯ 42 lines hidden (lines 1–42)
⋯ 94 lines hidden (lines 63–156)
scan_candidates: emit the considered/selected/dropped counts as span attributes and a structured scan_tick log.
src/froot/workflow/activities.py · 473 lines
⋯ 113 lines hidden (lines 1–113)
⋯ 313 lines hidden (lines 161–473)
How it was verified
make check is green — ruff format + lint, mypy strict, 290 tests at 77% coverage (floor 72%) — and both determinism gates pass. The feature was put through a three-dimension adversarial review (correctness · sandbox/determinism · MHE-honesty) with findings independently verified and folded in:
- a div-by-zero guard in class_earned for a degenerate min_decided; - an aware-datetime coercion at the GitHub boundary (a naive timestamp would have crashed the read-model's now - when subtraction); - the reclaim-only-when-earned honesty fix (above); - a class-accurate footer that names the shadow gate; - an advisory caveat on the gate badge; - the substantive-blocker-first reason ordering (above).
New tests cover the pure policy, the windowing and reclaim logic, the loop-label parse, the settings, the no-op telemetry helper, and the rendered panel + badge.