ADR-0094: Two-level convergence: Gate + ConvergenceLedger¶
- Status: Accepted
- Date: 2026-06-30
- Supersedes: none
- Related: ADR-0001 (five concurrent async loops), ADR-0002 (labels as state machine), ADR-0029 (caretaker loop pattern), ADR-0049 (kill-switch convention), ADR-0051 (iterative production-readiness review), ADR-0059 (advisor-pattern self-repairing review)
- Enforcement: enforced
- Enforced by: pytest:tests/test_convergence_ledger.py pytest:tests/test_convergence_gate.py pytest:tests/scenarios/test_convergence_review_mockworld.py pytest:tests/sandbox_scenarios/scenarios/s50_convergence_review.py
Precedent: Fixpoint iteration to convergence (Kleene's fixed-point theorem; Kildall's dataflow-analysis fixpoint, 1973) driven by an independent referee (separation of duties)
Divergence: classical fixpoint iteration converges a deterministic monotone function, but here each step is a stochastic generative-model stage judged by an independent gate that never grades its own output, so termination is not guaranteed by monotonicity — it is bounded by an explicit lap budget (max_convergence_laps) plus finding-signature oscillation detection escalating to HITL (receipt: ADR-0099, ADR-0051)
Superseded in part by ADR-0102: the
convergence_gate_enabledflag has been removed; the convergence gate is now the sole, always-on review path and the legacy ungated fallback is deleted. The flag-gated / dark-ship framing below is historical.
Context¶
HydraFlow's pipeline already converged in places, but unevenly, and nothing represented "this issue has converged across the whole pipeline."
Two concrete gaps motivated this work:
- Inner convergence was non-uniform. Three different engines did the same job with different guarantees:
AdversarialRetryLoop(Discover/Shape/Plan critics, with budget + oscillation detection), Implement's hand-rolledfor attempt in range(max)loops (no oscillation detection, no concern-forwarding), and Shape's one-off evaluator retry. The review-fix retry was a flat per-stage counter. - There was no outer fixpoint object. The "outer loop" was emergent: label requeue (ADR-0002) plus a half-dozen separate per-issue counters scattered across
state/(review_attempts,auto_agent_attempts,sandbox_failure_fixer_attempts,quality_fix_attempts,review_blast_radii). Cross-stage feedback was hand-wired per pair. Oscillation detection existed only inside stages, never across them, so the outer loop could ping-pong with nothing to catch it.
The target is a two-level Ralph loop: every stage loops until an independent referee says its goal is met (inner), and the pipeline cycles the issue until a full pass clears every gate (outer), with both levels oscillation-safe and HITL as a floor rather than a per-step clock.
This ADR records the foundation (Phase 1): the Gate referee, the ConvergenceLedger, and the wiring of both to the Review reject boundary as the proof point.
Decision¶
D1: Gate referee: hybrid, blast-radius-scaled¶
A uniform Gate abstraction (src/convergence_gate.py) renders one of three decisions: ADVANCE, LOOP_BACK(target, feedback), ESCALATE(reason). The concrete HybridGate evaluates in this order:
- Run the deterministic check. If it is not green, return
LOOP_BACKimmediately. The judge never runs (a judge cannot wave through a red deterministic signal). - Otherwise run
Nindependent judge passes, whereN = review_advisor.min_review_passes_for_blast_radius(blast_radius)(low 1 / medium 2 / high 3). The blast-radius table is the single source of truth from ADR-0051; the gate does not invent a second one. - If all judges approve,
ADVANCE. If any vetoes,LOOP_BACKwhile attempts remain, elseESCALATE.
The judge runs as an independent role (the hydraflow-review-advisor dispatch from ADR-0059), never the same invocation that produced the artifact, so the gate cannot rubber-stamp its own output.
D2: Outer loop: requeue + ConvergenceLedger¶
The ADR-0001 independent loops and ADR-0002 label state machine are preserved. A first-class per-issue ConvergenceLedger (Pydantic model on StateData, accessed via ConvergenceStateMixin) becomes the single source of truth for per-issue convergence state: laps, blast_radius, stage_state (per-stage attempts + last_verdict + last_finding_signatures), open_concerns, lap_signatures, and converged.
No dual-write. Every per-issue counter has exactly one owner. Phase 1 deletes the legacy StateData.review_attempts and StateData.review_blast_radii fields and moves that state into the ledger; the public accessor names (get_review_attempts, increment_review_attempts, reset_review_attempts, set/get_review_blast_radius, min_review_passes_required) are preserved on StateTracker, delegating to the ledger, so existing call sites are unchanged.
Storage / decision split¶
The "do not coexist" rule forced a clean layering that also resolves the kill-switch tension:
- Ledger = storage layer, always on. Plain persisted state, behind no flag.
- Gate = decision layer, flag-gated by
convergence_gate_enabled(defaultFalse, opt-in rollout). Disabling the gate reverts the decision to the legacy path, which still reads attempt state from the ledger (the legacy counters are gone). The flag toggles how the verdict is computed, not where the count lives.
Consequence: the kill switch reverts the decision, not the storage. Full rollback of the storage change is a revert PR, not a flag. That is the accepted price of no-coexist.
Outer convergence and oscillation¶
- Converged (issue done): a full lap where every visited gate returned
ADVANCEandopen_concernsis empty (recompute_converged). - Outer oscillation:
detect_outer_oscillationescalates to HITL when a finding-signature set repeats across laps, liftingAdversarialRetryLoop's in-stage idea to the pipeline level. - Lap budget:
max_convergence_laps(default 3) caps outer laps; exhaustion converts aLOOP_BACKintoESCALATE.
Rules and decisions discovered during implementation¶
These are load-bearing and were settled while wiring Phase 1. They are recorded here because they are non-obvious:
-
At the Review reject boundary, escalation is governed by the outer lap budget (
max_convergence_laps), not the legacy per-stagemax_review_fix_attempts. Because the reject path's deterministic signal is always red,HybridGateloops back unconditionally and never reaches its per-stage attempt-cap branch; the cap that bounds the review→implement→review cycle is the outer lap budget plus oscillation. A review→implement→review round-trip is a cross-stage outer lap, so this is the intended unification, not a regression. When the flag is on,max_review_fix_attemptsis superseded at this boundary; this is documented on theconvergence_gate_enabledconfig field to prevent operator surprise. Under default config the two coincide (the legacy cap of 2 andmax_convergence_lapsof 3 both escalate on the third review), so existing behavior is preserved. -
ledger.convergedis a Phase-2-meaningful field. Phase 1 wires the gate only into_handle_rejected_review; the APPROVE → merge path is ungated.recompute_convergedtherefore never records anADVANCEfor the review stage, soconvergedstaysFalsethroughout Phase 1. The ledger's Phase-1 value is attempt/lap tracking, oscillation detection, and the lap budget at the reject boundary.convergedbecomes meaningful when the APPROVE path is gated (Phase 2). Tests and the sandbox scenario assert what Phase 1 actually produces (loop-back recorded,laps >= 1, reviewlast_verdict == "LOOP_BACK"), notconverged. -
Failure-soft, never deadlock. A deterministic-check infrastructure failure is treated as red (
LOOP_BACK), since green cannot be proven. A judge-dispatch failure callsreraise_on_credit_or_bug(exc)first (so credit exhaustion and likely bugs propagate, never swallowed), then degrades to the documented per-gate default verdict (APPROVEat Review unlessHYDRAFLOW_REVIEW_POSTVERIFY_FAIL_AS_VETO=true, matching ADR-0059). -
Migration is non-crashing, with an accepted transient loss. Deleting
review_attempts/review_blast_radiiis safe becauseStateDataignores unknown keys: a pre-changestate.jsonloads cleanly andconvergence_ledgersdefaults to empty. Issues mid-review-retry at the moment of upgrade reset their review-fix count and blast radius to defaults. This is acceptable because both drive a soft retry budget, not a correctness invariant; the worst case is one slightly more generous retry round after deploy.
Scope (Phase 1)¶
The gate is wired to exactly one boundary: the Review reject decision (_handle_rejected_review_gated). The APPROVE → merge path and PostVerifyAdvisor are intentionally unchanged. Running the hybrid judge as N-passes-by-blast-radius on the APPROVE path tangles with PostVerifyAdvisor's existing veto/retry loop and is deferred to Phase 2. Phase 1 proves the gate, the ledger, the outer lap budget, and oscillation detection at a real boundary with bounded blast radius. Phase 2 rolls the gate to other boundaries and gates the APPROVE path; Phase 3 folds Implement's hand-rolled loops onto the unified engine.
Telemetry / observability¶
The full ConvergenceLedger per issue is exposed in /api/state under convergence_ledgers (no dashboard change was required). The sandbox e2e scenario asserts against this surface. Per-decision gate event emission is deferred to a later phase.
Consequences¶
- One convergence primitive and one outer-loop object replace scattered counters and ad hoc cross-stage feedback at the Review boundary.
- The kill switch makes the decision reversible by flag; the storage change is reversible only by revert.
- The feature ships dark (flag default off); enabling it changes the review-fix cap from a per-stage count to the outer lap budget, documented on the config field.
- The full test pyramid ships: unit (
tests/test_convergence_ledger.py,tests/test_convergence_gate.py), MockWorld scenario (tests/scenarios/test_convergence_review_mockworld.py), sandbox e2e (tests/sandbox_scenarios/scenarios/s50_convergence_review.py).
Alternatives considered¶
- Deterministic-only or judge-only gates. Rejected. Deterministic-only is blind to semantic quality; judge-only risks the judge grading the worker. The hybrid (deterministic must be green AND an independent judge signs off, blast-radius-scaled) is the strongest against rubber-stamping.
- Blocking shepherd for the outer loop (one worker walks an issue stage-to-stage in a single fixpoint). Rejected in favor of requeue + ledger to preserve the ADR-0001 concurrency model; the fixpoint is distributed across the existing loops, with the ledger as the shared truth and oscillation detection as the safety net. Narrowed by ADR-0137: this rejection is scoped to the convergence outer loop. A fenced, WIP-capped, labels-authoritative
IssueDriver— one that releases capacity when not working, re-reads the label at every boundary, and is admitted and bounded by code — is a distinct design governed by ADR-0137. Every other decision in this ADR is unaffected. - Coexistence of legacy counters with the ledger (dual-write). Rejected. No datum is written in two places; the ledger is the sole owner, migrated by move-not-copy.
- Honoring
max_review_fix_attemptsas a second cap in the gated path. Rejected. It would create a confusing interaction between two independent caps; the outer lap budget is the single, principled bound, documented on the config.
When to supersede this ADR¶
Supersede when the APPROVE path is gated (making converged live), when the gate is rolled to boundaries beyond Review, or when the lap budget proves the wrong bound for the review-fix cycle in practice.
Source-file citations¶
src/convergence_gate.py:Gate,GateDecision,GateResult,GateContext,DetResult,JudgeVerdict,HybridGate,build_review_gate.src/models.py:StageRecord,ConvergenceLedger,StateData.convergence_ledgers.src/state/_convergence.py:ConvergenceStateMixin(ledger accessors + the review-attempt/blast-radius delegations).src/review_phase/_phase.py:_uses_convergence_gate,_convergence_decision,_handle_rejected_review_gated.src/config.py:convergence_gate_enabled,max_convergence_laps.src/review_advisor.py:min_review_passes_for_blast_radius,compute_blast_radius(reused, not duplicated).