A second loop, same chassis
froot grows a second loop
Replicate the chassis — prove the spine generalizes by running a different loop on it.
froot's charter says a second loop should be nearly free: the durable machinery is identical for every loop, and only the signal, the candidate policy, and a little namespacing make a loop a specialist. This PR tests that claim by adding security-patch — bump a dependency to the lowest version that clears a known advisory — beside the existing dependency-patch loop. The proof is in what did not change. Expand any block to read the surrounding code.
What "a second loop" actually meant
A loop is mostly chassis: a durable schedule, a checkout, the wait on CI, the PR plumbing, the recorded outcome, close-on-red, reconcile. Only three small things make a loop a specialist: the signal (what to propose), the candidate policy (which version), and a little namespacing (so two loops never collide). So security-patch was never going to be a rewrite.
The tour follows the change from the inside out: the Loop seam and how the pure spine stays blind to it; the generalized candidate; the new OSV signal and the version logic that turns it into a target; the loop-aware reconcile; the proof; and a verdict scored on the empty diff in the spine.
One enum, and a spine that never sees it
src/froot/domain/loop.pysrc/froot/policy/naming.pysrc/froot/workflow/bump_workflow.pyThe whole parameterization rides on one enum, modeled on the Ecosystem enum froot already used. Its value is the kebab name that namespaces everything the loop owns — its branches, its label, its workflow ids, its logs.
src/froot/domain/loop.py · 24 lines
⋯ 12 lines hidden (lines 1–12)
Branches namespace naturally (froot/<loop>/…), so two loops can't push the same branch even for the same package and version. The workflow ids take more care: a deployed dependency-patch loop is already running under its old ids, so the default loop keeps them byte-for-byte and only other loops carry a segment.
src/froot/policy/naming.py · 121 lines
⋯ 30 lines hidden (lines 1–30)
⋯ 15 lines hidden (lines 53–67)
⋯ 37 lines hidden (lines 85–121)
src/froot/workflow/bump_workflow.py · 173 lines
⋯ 97 lines hidden (lines 1–97)
⋯ 65 lines hidden (lines 109–173)
One candidate type, two loops' reach
src/froot/domain/candidate.pydependency-patch only ever proposes a patch; security-patch must reach a minor or major to clear a CVE. So the candidate type can no longer enforce "patch-only" — that decision moves into the per-loop selection policy, and the type keeps only what is true for any loop: a stable target newer than current.
src/froot/domain/candidate.py · 105 lines
⋯ 23 lines hidden (lines 1–23)
⋯ 45 lines hidden (lines 61–105)
The installed set, and one source for every ecosystem
src/froot/adapters/npm.pysrc/froot/adapters/osv.pyQ1. What does security-patch read?
Not "what's outdated" — what's installed. A new list_installed reads the direct dependencies and their locked versions from the lockfile (no install). Only direct deps: froot can bump those; a transitive vuln needs its parent moved, which is not a bump froot makes.
src/froot/adapters/npm.py · 193 lines
⋯ 148 lines hidden (lines 1–148)
⋯ 15 lines hidden (lines 179–193)
Q2. Where do advisories come from?
OSV.dev — Google's cross-ecosystem vulnerability database, which speaks both npm and PyPI. So a single adapter covers every ecosystem froot patches. Two calls: a batch query of the installed set returns the vuln ids per package, then each vuln record is fetched for its affected ranges. Best-effort by contract.
src/froot/adapters/osv.py · 162 lines
⋯ 94 lines hidden (lines 1–94)
⋯ 27 lines hidden (lines 136–162)
The lowest version that clears them all
src/froot/policy/candidates.pyThis is the one piece of genuinely new logic, and it is pure — fed raw advisory facts, tested without the network. For one advisory it finds the affected range holding the installed version and returns its fix; an unfixed or unparseable range yields nothing (froot won't propose a bump it can't reason about).
src/froot/policy/candidates.py · 163 lines
⋯ 74 lines hidden (lines 1–74)
⋯ 70 lines hidden (lines 94–163)
A package can have several advisories. The target is the max of the per-advisory fixes — the lowest version that clears all of them at once.
src/froot/policy/candidates.py · 163 lines
⋯ 113 lines hidden (lines 1–113)
One activity, two signals; reconcile per loop
src/froot/workflow/activities.pysrc/froot/policy/reconcile.pyThe scan activity is where the two signals diverge — and the only place they do. A match on the loop picks dependency-patch's "available upgrades → highest patch" or security-patch's "installed set → OSV → clearing target". Both lazy- import their stack per arm and both feed a pure selection policy.
src/froot/workflow/activities.py · 435 lines
⋯ 63 lines hidden (lines 1–63)
⋯ 346 lines hidden (lines 90–435)
Reconcile (the stale-PR cleanup from Phase 1) is generalized to take the loop's candidates and a loop, and is scoped to that loop's branch namespace — so the dependency-patch sweep can never close a security PR, and vice versa.
src/froot/policy/reconcile.py · 120 lines
⋯ 49 lines hidden (lines 1–49)
⋯ 36 lines hidden (lines 85–120)
Fixtures for the logic, mock-HTTP for the adapter
tests/test_security_candidates.pytests/test_osv.pytests/test_e2e_workflow.pyThe version logic — the part most worth getting right — is pinned by pure fixture tests: the holding-range pick, the max-of-fixes, an unfixed advisory dropped, a fix at-or-below installed dropped, the partial-fix honesty, a uv case.
tests/test_security_candidates.py · 127 lines
⋯ 7 lines hidden (lines 1–7)
⋯ 80 lines hidden (lines 48–127)
The OSV adapter itself — the two-call flow, the JSON shaping, the query/result alignment, the best-effort error path — is exercised against an in-memory httpx.MockTransport, no network. (This test was added in review: the adapter's advisories() had been left untested.)
tests/test_osv.py · 171 lines
⋯ 41 lines hidden (lines 1–41)
⋯ 93 lines hidden (lines 79–171)
tests/test_e2e_workflow.py · 184 lines
⋯ 124 lines hidden (lines 1–124)
⋯ 40 lines hidden (lines 145–184)
Did the chassis hold?
The claim is that a second loop is a signal, a candidate policy, and some namespacing — with the durable machinery untouched. Below, that claim is scored against the change, with the file that proves each row.
| Claim | Upheld by | Where |
|---|---|---|
| A loop is one signal + one candidate policy | security-patch adds an OSV adapter + select_security_candidates; the scan activity dispatches by loop | adapters/osv.py, policy/candidates.py, workflow/activities.py |
| The pure spine never learns the loop | loop lives on the params; _execute hands it to the activities; the state-machine diff is empty | workflow/bump_workflow.py, policy/state_machine.py (unchanged) |
| The candidate type generalized cleanly | patch-only moved to the policy; the type keeps stable-and-newer + a justification | domain/candidate.py, policy/candidates.py |
| The two loops never collide | branch + label + id all namespace the loop; reconcile is loop-scoped | policy/naming.py, policy/reconcile.py |
| Security targets clear the advisory, honestly | max-of-fixes; a partial fix says so; unparseable fixes dropped | policy/candidates.py |
| Risky bumps are safe to attempt | a red security bump auto-closes (Phase 1's close-on-red, untouched) | policy/state_machine.py (unchanged) |
Thirteen findings from the adversarial review are folded in, including a critical one a code reviewer would have missed: the dashboard generated only dependency-patch scan ids, so a running security scan showed as dead. It now derives liveness per (repo, loop). The partial-fix honesty (§4) and the OSV adapter test (§6) came from the same pass. One presentation follow-up is named in the PR: the dashboard's bump aggregates still combine both loops; the data is namespaced, the grouping is not yet.