Overview — the second ring
The determinism kernel (PR #2) is a lexical CI gate: it flags a nondeterministic call written directly inside an @workflow.defn class. This PR adds the transitive ring — what the kernel structurally can't see: a hazard reached through a first-party helper (up to two call levels out), or a risky third-party import at a workflow module's scope.
It's a durable, PR-reactive reviewer loop, built in froot's pure-spine style — a sibling of the scan/bump loops. Two new workflows, a pure call-graph analyzer, one model call, and an advisory PR comment.
The analyzer — matcher + bounded call-graph
policy/determinism.py is froot's own copy of the kernel matcher (check_node — the four banned tables + import resolution), wrapped in a depth-bounded walk. For each workflow class it scans the class body lexically (those hits are the kernel's, kept as lexical), then chases first-party free-function calls out of the class, running the same matcher on each reachable body. A hit there is a confirmed transitive HazardPath.
check_node — the shared banned-call matcher · 427 lines
⋯ 156 lines hidden (lines 1–156)
⋯ 241 lines hidden (lines 187–427)
analyze_workflow_surface — the bounded BFS · 427 lines
⋯ 314 lines hidden (lines 1–314)
⋯ 30 lines hidden (lines 398–427)
The edge resolver is the precision lever
_first_party_callees decides what counts as an edge to chase: a call whose callee resolves — through the module's import map — to a known first-party free function. A Name via a from x import f, a same-module function, or a mod.func attribute all resolve; everything else is dropped.
_first_party_callees — resolve, then keep only first-party defs · 427 lines
⋯ 239 lines hidden (lines 1–239)
⋯ 146 lines hidden (lines 282–427)
Two workflows — and why the brain passes its own gate
ReviewWorkflow is the durable per-repo loop: list open PRs, dispatch a review each (idempotent per PR + head SHA), sleep, continue-as-new — a near-exact mirror of ScanWorkflow. PrReviewWorkflow is the per-PR pipeline: analyze → adjudicate the frontier (only if there is one) → synthesize (pure) → post.
PrReviewWorkflow.run — the linear pipeline · 65 lines
⋯ 33 lines hidden (lines 1–33)
The activities — the impure boundary
Five new activities, each a thin wrapper with lazy adapter imports (keeping HTTP/model stacks out of the sandbox), mirroring the bump loop's conventions. analyze_pr checks out the PR head, loads the first-party module ASTs, and runs the pure analyzer. dispatch_pr_review is idempotent per (PR, head SHA) via REJECT_DUPLICATE.
dispatch_pr_review + analyze_pr · 261 lines
⋯ 177 lines hidden (lines 1–177)
⋯ 40 lines hidden (lines 222–261)
The model frontier and the advisory comment
The static pass does the heavy lifting. The model adjudicates only the residual it can't resolve: a risky third-party import at a workflow module's scope (is it actually reached, or dead weight?). synthesize_findings then combines static hazards with the model-confirmed frontier, and render_review_comment emits one marker-tagged comment.
synthesize_findings — static ∪ (model == yes) · 103 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 34 lines hidden (lines 70–103)
| Concern | How it's handled |
|---|---|
| Comment stacking | marker-tagged, upserted in place each new head SHA |
| Score / ledger | froot.review telemetry → derive hazard-resolved rate |
| Honesty | each finding tagged static call-path vs model-assessed; comment says advisory |
| Model cost | bounded — only the import frontier, not every reachable body |
Forge, settings, wiring
The Forge protocol gains three methods. checkout_pull_request uses refs/pull/N/head — the ref the base repo exposes for every PR — so a fork PR checks out exactly like a same-repo one. list_open_pull_requests feeds the loop; upsert_issue_comment finds-or-edits the marker comment.
checkout_pull_request — fork-safe via refs/pull/N/head · 351 lines
⋯ 167 lines hidden (lines 1–167)
⋯ 162 lines hidden (lines 190–351)
Rounding it out: ReviewSettings (FROOT_REVIEW_* — enabled / poll interval / depth), review_starter (boots one loop per repo, gated by enabled), the two workflows + five activities registered in runtime.py, and the review_workflow_id / pr_review_workflow_id naming keys.
What the adversarial review caught
Before this PR, four independent reviewers (analyzer correctness, Temporal replay-safety, GitHub I/O, spec alignment) swept the diff and each finding was verified. Four real defects were confirmed and fixed:
| Defect | Fix |
|---|---|
from ..random import x mis-binds (analyzer _import_map) | skip all relative imports (level > 0) |
| relative import falsely flagged as risky third-party | _risky_imports requires level == 0 |
| kernel matcher: same relative-import bug → a false positive in the blocking gate | same guard in scripts/check_determinism.py |
checkout_branch hard-fails on fork PRs | fetch refs/pull/N/head instead |
Verified end to end: ruff, mypy strict (92 files), 171 tests, the determinism gate on the brain's own workflows, and zero false positives running the analyzer across 143 real modules in froot + ynab-agent.