Skip to main content

codewhale_workflow/
review_repair.rs

1//! Bounded review→repair automation (#3832).
2//!
3//! ## What this is not
4//!
5//! It is **not a fourth Mode.** Codewhale has exactly three Modes — Plan, Act,
6//! Operate — and exactly three permission postures — Ask, Auto-Review, Full
7//! Access. A review→repair loop is a *Workflow shape*: an ordering of existing
8//! roles over existing gates. It therefore lives here, beside [`crate::gates`],
9//! and carries no mode, no posture, and no permission of its own. Whatever Mode
10//! and posture the session already has continue to govern every step; this type
11//! only decides *when to stop*.
12//!
13//! ## The four things that make it safe
14//!
15//! 1. **Explicit ceilings.** [`ReviewRepairBounds`] caps iterations, wall-clock
16//!    seconds, and tool calls. Every ceiling is checked *before* work starts, so
17//!    the loop cannot overrun by one extra iteration; a zero ceiling means the
18//!    loop never runs rather than running forever.
19//! 2. **Exact routes on the record.** Each iteration must carry a
20//!    [`RouteReceipt`] for the reviewer and, where the policy requires one, the
21//!    verifier: role, exact provider/model, requested→effective reasoning, and
22//!    who routed it. An iteration with no reviewer route is refused.
23//! 3. **Human ratification where policy says.** When
24//!    [`ReviewRepairPolicy::require_human_ratification`] is set, a clean verify
25//!    does not finish the loop: it parks at
26//!    [`StopReason::AwaitingRatification`] until [`ReviewRepairLoop::ratify`]
27//!    records an explicit human decision.
28//! 4. **Fail closed on stale input.** The loop pins the digest of the artifact
29//!    under review. If an iteration reports a different digest — the branch
30//!    moved, the diff was rebuilt, the findings came from an older tree — the
31//!    loop halts at [`StopReason::StaleInput`] instead of repairing against
32//!    something the reviewer never saw.
33
34use serde::{Deserialize, Serialize};
35
36/// Explicit ceilings on a review→repair loop. There is no unbounded variant.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ReviewRepairBounds {
39    /// Maximum review→repair iterations. `0` means the loop never starts.
40    pub max_iterations: u32,
41    /// Maximum cumulative wall-clock seconds across all iterations.
42    pub max_wall_clock_secs: u64,
43    /// Maximum cumulative tool calls across all iterations.
44    pub max_tool_calls: u32,
45}
46
47impl ReviewRepairBounds {
48    /// Conservative defaults for an unattended loop: short, cheap, and easy to
49    /// re-run by hand if it stops early.
50    #[must_use]
51    pub fn conservative() -> Self {
52        Self {
53            max_iterations: 3,
54            max_wall_clock_secs: 900,
55            max_tool_calls: 120,
56        }
57    }
58}
59
60impl Default for ReviewRepairBounds {
61    fn default() -> Self {
62        Self::conservative()
63    }
64}
65
66/// Who chose the route for a step. Distinguishing these is the difference
67/// between "the user picked this model" and "a Router picked it".
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum RoutedBy {
71    /// The exact Fleet member route, frozen at Fleet capture.
72    Fleet,
73    /// A Router chose reasoning for the frozen member route. The Router's own
74    /// exact identity is recorded so its spend is attributable.
75    Router { provider: String, model: String },
76    /// The session's current model selection.
77    SessionDefault,
78}
79
80impl RoutedBy {
81    /// Human-readable source label for receipts and Workflow rows.
82    #[must_use]
83    pub fn label(&self) -> String {
84        match self {
85            Self::Fleet => "fleet".to_string(),
86            Self::Router { provider, model } => format!("router {provider}/{model}"),
87            Self::SessionDefault => "session default".to_string(),
88        }
89    }
90}
91
92/// The exact route a reviewer or verifier ran on.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct RouteReceipt {
95    /// Fleet role (`reviewer`, `verifier`, `implementer`, …).
96    pub role: String,
97    pub provider: String,
98    pub model: String,
99    /// What the plan asked for.
100    pub requested_reasoning: String,
101    /// What the provider actually applied. Divergence is shown, not smoothed.
102    pub effective_reasoning: String,
103    pub routed_by: RoutedBy,
104}
105
106impl RouteReceipt {
107    /// True when every identity field is present. An incomplete route is not a
108    /// receipt; it is a claim.
109    #[must_use]
110    pub fn is_complete(&self) -> bool {
111        !self.role.trim().is_empty()
112            && !self.provider.trim().is_empty()
113            && !self.model.trim().is_empty()
114            && !self.requested_reasoning.trim().is_empty()
115            && !self.effective_reasoning.trim().is_empty()
116    }
117
118    /// One-line display: role, exact route, requested→effective, routed by.
119    #[must_use]
120    pub fn display_line(&self) -> String {
121        let reasoning = if self.requested_reasoning == self.effective_reasoning {
122            self.effective_reasoning.clone()
123        } else {
124            format!("{}→{}", self.requested_reasoning, self.effective_reasoning)
125        };
126        format!(
127            "{}: {}/{} reasoning {reasoning} (routed by {})",
128            self.role,
129            self.provider,
130            self.model,
131            self.routed_by.label()
132        )
133    }
134}
135
136/// The verdict a reviewer/verifier pair produced for one iteration.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum IterationVerdict {
140    /// Review and verification both passed. The loop may finish.
141    Clean,
142    /// Findings remain; another repair iteration is warranted.
143    RepairsNeeded,
144    /// The reviewer refused to judge (missing input, tool failure, …). The loop
145    /// stops rather than treating an absent judgment as a pass.
146    Inconclusive,
147}
148
149/// What one iteration actually did.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct IterationReceipt {
152    /// 1-based iteration number, assigned by the loop.
153    pub iteration: u32,
154    /// Digest of the artifact this iteration reviewed.
155    pub input_digest: String,
156    pub reviewer: RouteReceipt,
157    /// Present when the policy requires a separate verify step.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub verifier: Option<RouteReceipt>,
160    pub verdict: IterationVerdict,
161    /// Bounded finding summaries. Not the findings themselves.
162    #[serde(default)]
163    pub finding_summaries: Vec<String>,
164    pub tool_calls_used: u32,
165    pub elapsed_secs: u64,
166}
167
168/// Why a review→repair loop stopped. Every variant is terminal.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum StopReason {
172    /// Clean verdict, and either no ratification was required or it was given.
173    Verified,
174    /// A human explicitly rejected the result.
175    RatificationDeclined {
176        note: String,
177    },
178    /// Clean verdict, waiting on the human the policy requires.
179    AwaitingRatification,
180    IterationCeiling {
181        max_iterations: u32,
182    },
183    TimeCeiling {
184        max_wall_clock_secs: u64,
185        elapsed_secs: u64,
186    },
187    ToolCeiling {
188        max_tool_calls: u32,
189        used: u32,
190    },
191    /// The artifact under review changed underneath the loop.
192    StaleInput {
193        pinned: String,
194        reported: String,
195    },
196    /// The reviewer could not judge, or its receipt was incomplete.
197    Inconclusive {
198        reason: String,
199    },
200}
201
202impl StopReason {
203    /// True only for the one outcome that means "this loop succeeded".
204    #[must_use]
205    pub fn is_success(&self) -> bool {
206        matches!(self, Self::Verified)
207    }
208
209    /// True when the loop stopped because it hit an explicit ceiling. These are
210    /// honest incompletions, not failures.
211    #[must_use]
212    pub fn is_ceiling(&self) -> bool {
213        matches!(
214            self,
215            Self::IterationCeiling { .. } | Self::TimeCeiling { .. } | Self::ToolCeiling { .. }
216        )
217    }
218
219    /// Stable receipt line. UI surfaces localize around it; the numbers here are
220    /// the ones the user must be able to check.
221    #[must_use]
222    pub fn receipt(&self) -> String {
223        match self {
224            Self::Verified => "verified".to_string(),
225            Self::RatificationDeclined { note } => format!("ratification declined: {note}"),
226            Self::AwaitingRatification => {
227                "clean; awaiting human ratification before this counts as done".to_string()
228            }
229            Self::IterationCeiling { max_iterations } => {
230                format!("stopped at the iteration ceiling ({max_iterations})")
231            }
232            Self::TimeCeiling {
233                max_wall_clock_secs,
234                elapsed_secs,
235            } => format!("stopped at the time ceiling ({elapsed_secs}s of {max_wall_clock_secs}s)"),
236            Self::ToolCeiling {
237                max_tool_calls,
238                used,
239            } => format!("stopped at the tool ceiling ({used} of {max_tool_calls} calls)"),
240            Self::StaleInput { pinned, reported } => format!(
241                "stopped: input changed under review (pinned {pinned}, reported {reported})"
242            ),
243            Self::Inconclusive { reason } => format!("stopped: no usable verdict ({reason})"),
244        }
245    }
246}
247
248/// Policy knobs that are not ceilings.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ReviewRepairPolicy {
251    /// A clean verdict parks at [`StopReason::AwaitingRatification`] until a
252    /// human decides.
253    #[serde(default)]
254    pub require_human_ratification: bool,
255    /// Every iteration must carry a verifier receipt in addition to the
256    /// reviewer's. A missing verifier is inconclusive, never a pass.
257    #[serde(default)]
258    pub require_verifier_receipt: bool,
259}
260
261impl Default for ReviewRepairPolicy {
262    fn default() -> Self {
263        // Fail closed by default: a human confirms, and review alone is not
264        // verification.
265        Self {
266            require_human_ratification: true,
267            require_verifier_receipt: true,
268        }
269    }
270}
271
272/// A bounded review→repair loop over one lane's artifact.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct ReviewRepairLoop {
275    pub lane_id: String,
276    pub bounds: ReviewRepairBounds,
277    pub policy: ReviewRepairPolicy,
278    /// Digest of the artifact this loop was authorized to work on.
279    pub pinned_input_digest: String,
280    #[serde(default)]
281    pub iterations: Vec<IterationReceipt>,
282    #[serde(default)]
283    pub elapsed_secs: u64,
284    #[serde(default)]
285    pub tool_calls_used: u32,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub stopped: Option<StopReason>,
288}
289
290/// Refusal from [`ReviewRepairLoop::begin_iteration`] or
291/// [`ReviewRepairLoop::record_iteration`].
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum ReviewRepairError {
294    /// The loop already stopped; it does not restart.
295    AlreadyStopped(StopReason),
296    /// A ceiling or fail-closed condition ended the loop on this call.
297    Stopped(StopReason),
298    /// The receipt itself was unusable.
299    IncompleteReceipt(String),
300}
301
302impl ReviewRepairError {
303    /// The terminal reason, when this refusal carries one.
304    #[must_use]
305    pub fn stop_reason(&self) -> Option<&StopReason> {
306        match self {
307            Self::AlreadyStopped(reason) | Self::Stopped(reason) => Some(reason),
308            Self::IncompleteReceipt(_) => None,
309        }
310    }
311}
312
313impl std::fmt::Display for ReviewRepairError {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        match self {
316            Self::AlreadyStopped(reason) => {
317                write!(f, "review-repair already stopped: {}", reason.receipt())
318            }
319            Self::Stopped(reason) => write!(f, "{}", reason.receipt()),
320            Self::IncompleteReceipt(detail) => {
321                write!(f, "review-repair receipt is unusable: {detail}")
322            }
323        }
324    }
325}
326
327impl std::error::Error for ReviewRepairError {}
328
329impl ReviewRepairLoop {
330    /// Start a loop pinned to the digest of the artifact under review.
331    #[must_use]
332    pub fn new(
333        lane_id: impl Into<String>,
334        pinned_input_digest: impl Into<String>,
335        bounds: ReviewRepairBounds,
336        policy: ReviewRepairPolicy,
337    ) -> Self {
338        Self {
339            lane_id: lane_id.into(),
340            bounds,
341            policy,
342            pinned_input_digest: pinned_input_digest.into(),
343            iterations: Vec::new(),
344            elapsed_secs: 0,
345            tool_calls_used: 0,
346            stopped: None,
347        }
348    }
349
350    /// Whether the loop has stopped, and why.
351    #[must_use]
352    pub fn stop_reason(&self) -> Option<&StopReason> {
353        self.stopped.as_ref()
354    }
355
356    /// Claim the next iteration slot, or refuse.
357    ///
358    /// Ceilings are checked here — *before* any model or tool spend — so a loop
359    /// that has exhausted its budget cannot buy one more iteration to find out.
360    pub fn begin_iteration(&mut self) -> Result<u32, ReviewRepairError> {
361        if let Some(reason) = self.stopped.clone() {
362            return Err(ReviewRepairError::AlreadyStopped(reason));
363        }
364        let next = self.iterations.len() as u32 + 1;
365        if next > self.bounds.max_iterations {
366            return Err(self.stop(StopReason::IterationCeiling {
367                max_iterations: self.bounds.max_iterations,
368            }));
369        }
370        if self.elapsed_secs >= self.bounds.max_wall_clock_secs {
371            return Err(self.stop(StopReason::TimeCeiling {
372                max_wall_clock_secs: self.bounds.max_wall_clock_secs,
373                elapsed_secs: self.elapsed_secs,
374            }));
375        }
376        if self.tool_calls_used >= self.bounds.max_tool_calls {
377            return Err(self.stop(StopReason::ToolCeiling {
378                max_tool_calls: self.bounds.max_tool_calls,
379                used: self.tool_calls_used,
380            }));
381        }
382        Ok(next)
383    }
384
385    /// Record a completed iteration and decide whether the loop continues.
386    ///
387    /// Returns `Ok(None)` when another iteration is warranted, `Ok(Some(reason))`
388    /// when the loop is done.
389    pub fn record_iteration(
390        &mut self,
391        receipt: IterationReceipt,
392    ) -> Result<Option<StopReason>, ReviewRepairError> {
393        if let Some(reason) = self.stopped.clone() {
394            return Err(ReviewRepairError::AlreadyStopped(reason));
395        }
396
397        // Fail closed on stale input before anything in the receipt is trusted:
398        // a verdict about a different tree is not a verdict about this one.
399        if receipt.input_digest != self.pinned_input_digest {
400            return Err(self.stop(StopReason::StaleInput {
401                pinned: self.pinned_input_digest.clone(),
402                reported: receipt.input_digest.clone(),
403            }));
404        }
405        if !receipt.reviewer.is_complete() {
406            return Err(ReviewRepairError::IncompleteReceipt(format!(
407                "reviewer route for iteration {} is missing identity fields",
408                receipt.iteration
409            )));
410        }
411        if self.policy.require_verifier_receipt {
412            match receipt.verifier.as_ref() {
413                None => {
414                    return Err(self.stop(StopReason::Inconclusive {
415                        reason: "policy requires a verifier receipt and none was produced"
416                            .to_string(),
417                    }));
418                }
419                Some(verifier) if !verifier.is_complete() => {
420                    return Err(ReviewRepairError::IncompleteReceipt(format!(
421                        "verifier route for iteration {} is missing identity fields",
422                        receipt.iteration
423                    )));
424                }
425                Some(_) => {}
426            }
427        }
428
429        self.elapsed_secs = self.elapsed_secs.saturating_add(receipt.elapsed_secs);
430        self.tool_calls_used = self.tool_calls_used.saturating_add(receipt.tool_calls_used);
431        let verdict = receipt.verdict;
432        self.iterations.push(receipt);
433
434        // Overrun is reported honestly: the ceiling is the reason we stop, even
435        // though this iteration's spend already happened.
436        if self.tool_calls_used > self.bounds.max_tool_calls {
437            let reason = StopReason::ToolCeiling {
438                max_tool_calls: self.bounds.max_tool_calls,
439                used: self.tool_calls_used,
440            };
441            self.stopped = Some(reason.clone());
442            return Ok(Some(reason));
443        }
444        if self.elapsed_secs > self.bounds.max_wall_clock_secs {
445            let reason = StopReason::TimeCeiling {
446                max_wall_clock_secs: self.bounds.max_wall_clock_secs,
447                elapsed_secs: self.elapsed_secs,
448            };
449            self.stopped = Some(reason.clone());
450            return Ok(Some(reason));
451        }
452
453        match verdict {
454            IterationVerdict::Inconclusive => {
455                let reason = StopReason::Inconclusive {
456                    reason: "reviewer returned no usable verdict".to_string(),
457                };
458                self.stopped = Some(reason.clone());
459                Ok(Some(reason))
460            }
461            IterationVerdict::Clean => {
462                let reason = if self.policy.require_human_ratification {
463                    StopReason::AwaitingRatification
464                } else {
465                    StopReason::Verified
466                };
467                self.stopped = Some(reason.clone());
468                Ok(Some(reason))
469            }
470            IterationVerdict::RepairsNeeded => {
471                if self.iterations.len() as u32 >= self.bounds.max_iterations {
472                    let reason = StopReason::IterationCeiling {
473                        max_iterations: self.bounds.max_iterations,
474                    };
475                    self.stopped = Some(reason.clone());
476                    return Ok(Some(reason));
477                }
478                Ok(None)
479            }
480        }
481    }
482
483    /// Record the human decision a parked loop is waiting for.
484    ///
485    /// Only a loop actually parked at [`StopReason::AwaitingRatification`] can be
486    /// ratified: a loop that stopped at a ceiling, on stale input, or
487    /// inconclusively cannot be waved through, because there is no clean result
488    /// to approve.
489    pub fn ratify(
490        &mut self,
491        approved: bool,
492        note: impl Into<String>,
493    ) -> Result<&StopReason, ReviewRepairError> {
494        match self.stopped.clone() {
495            Some(StopReason::AwaitingRatification) => {
496                self.stopped = Some(if approved {
497                    StopReason::Verified
498                } else {
499                    StopReason::RatificationDeclined { note: note.into() }
500                });
501                Ok(self.stopped.as_ref().expect("just set"))
502            }
503            Some(other) => Err(ReviewRepairError::AlreadyStopped(other)),
504            None => Err(ReviewRepairError::IncompleteReceipt(
505                "the loop has not produced a clean result to ratify".to_string(),
506            )),
507        }
508    }
509
510    /// Every route this loop ran, in order, for the Workflow row and receipts.
511    #[must_use]
512    pub fn route_lines(&self) -> Vec<String> {
513        let mut lines = Vec::new();
514        for iteration in &self.iterations {
515            lines.push(format!(
516                "#{} {}",
517                iteration.iteration,
518                iteration.reviewer.display_line()
519            ));
520            if let Some(verifier) = iteration.verifier.as_ref() {
521                lines.push(format!(
522                    "#{} {}",
523                    iteration.iteration,
524                    verifier.display_line()
525                ));
526            }
527        }
528        lines
529    }
530
531    /// One-line budget statement: what was used against what was allowed.
532    #[must_use]
533    pub fn budget_line(&self) -> String {
534        format!(
535            "iterations {}/{}, tools {}/{}, elapsed {}s/{}s",
536            self.iterations.len(),
537            self.bounds.max_iterations,
538            self.tool_calls_used,
539            self.bounds.max_tool_calls,
540            self.elapsed_secs,
541            self.bounds.max_wall_clock_secs,
542        )
543    }
544
545    fn stop(&mut self, reason: StopReason) -> ReviewRepairError {
546        self.stopped = Some(reason.clone());
547        ReviewRepairError::Stopped(reason)
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    fn route(role: &str) -> RouteReceipt {
556        RouteReceipt {
557            role: role.to_string(),
558            provider: "zhipu".to_string(),
559            model: "glm-5.2".to_string(),
560            requested_reasoning: "high".to_string(),
561            effective_reasoning: "high".to_string(),
562            routed_by: RoutedBy::Fleet,
563        }
564    }
565
566    fn receipt(iteration: u32, digest: &str, verdict: IterationVerdict) -> IterationReceipt {
567        IterationReceipt {
568            iteration,
569            input_digest: digest.to_string(),
570            reviewer: route("reviewer"),
571            verifier: Some(route("verifier")),
572            verdict,
573            finding_summaries: Vec::new(),
574            tool_calls_used: 5,
575            elapsed_secs: 10,
576        }
577    }
578
579    fn loop_with(bounds: ReviewRepairBounds, policy: ReviewRepairPolicy) -> ReviewRepairLoop {
580        ReviewRepairLoop::new("lane-1", "digest-a", bounds, policy)
581    }
582
583    #[test]
584    fn iteration_ceiling_stops_before_spending_another_turn() {
585        let bounds = ReviewRepairBounds {
586            max_iterations: 2,
587            ..ReviewRepairBounds::conservative()
588        };
589        let mut lane = loop_with(bounds, ReviewRepairPolicy::default());
590
591        for i in 1..=2 {
592            let n = lane.begin_iteration().expect("iteration within ceiling");
593            assert_eq!(n, i);
594            let stop = lane
595                .record_iteration(receipt(i, "digest-a", IterationVerdict::RepairsNeeded))
596                .expect("recorded");
597            if i < 2 {
598                assert!(stop.is_none());
599            } else {
600                assert_eq!(
601                    stop,
602                    Some(StopReason::IterationCeiling { max_iterations: 2 })
603                );
604            }
605        }
606
607        let err = lane.begin_iteration().expect_err("loop is done");
608        assert!(matches!(err, ReviewRepairError::AlreadyStopped(_)));
609        assert!(lane.stop_reason().expect("stopped").is_ceiling());
610        assert!(!lane.stop_reason().expect("stopped").is_success());
611    }
612
613    #[test]
614    fn zero_iteration_ceiling_never_starts() {
615        let mut lane = loop_with(
616            ReviewRepairBounds {
617                max_iterations: 0,
618                ..ReviewRepairBounds::conservative()
619            },
620            ReviewRepairPolicy::default(),
621        );
622        let err = lane
623            .begin_iteration()
624            .expect_err("a zero ceiling runs nothing");
625        assert_eq!(
626            err.stop_reason(),
627            Some(&StopReason::IterationCeiling { max_iterations: 0 })
628        );
629    }
630
631    #[test]
632    fn tool_and_time_ceilings_stop_the_loop_and_report_the_overrun() {
633        let mut lane = loop_with(
634            ReviewRepairBounds {
635                max_iterations: 5,
636                max_wall_clock_secs: 600,
637                max_tool_calls: 4,
638            },
639            ReviewRepairPolicy::default(),
640        );
641        lane.begin_iteration().expect("first iteration");
642        let stop = lane
643            .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded))
644            .expect("recorded");
645        assert_eq!(
646            stop,
647            Some(StopReason::ToolCeiling {
648                max_tool_calls: 4,
649                used: 5
650            })
651        );
652        assert!(lane.budget_line().contains("tools 5/4"));
653
654        let mut timed = loop_with(
655            ReviewRepairBounds {
656                max_iterations: 5,
657                max_wall_clock_secs: 5,
658                max_tool_calls: 100,
659            },
660            ReviewRepairPolicy::default(),
661        );
662        timed.begin_iteration().expect("first iteration");
663        let stop = timed
664            .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded))
665            .expect("recorded");
666        assert_eq!(
667            stop,
668            Some(StopReason::TimeCeiling {
669                max_wall_clock_secs: 5,
670                elapsed_secs: 10
671            })
672        );
673    }
674
675    #[test]
676    fn stale_input_fails_closed_without_repairing() {
677        let mut lane = loop_with(
678            ReviewRepairBounds::conservative(),
679            ReviewRepairPolicy::default(),
680        );
681        lane.begin_iteration().expect("first iteration");
682
683        let err = lane
684            .record_iteration(receipt(1, "digest-b", IterationVerdict::Clean))
685            .expect_err("a verdict about another tree is not a verdict about this one");
686
687        assert_eq!(
688            err.stop_reason(),
689            Some(&StopReason::StaleInput {
690                pinned: "digest-a".to_string(),
691                reported: "digest-b".to_string(),
692            })
693        );
694        assert!(lane.iterations.is_empty(), "stale work is not recorded");
695        assert!(!lane.stop_reason().expect("stopped").is_success());
696    }
697
698    #[test]
699    fn clean_verdict_parks_for_human_ratification() {
700        let mut lane = loop_with(
701            ReviewRepairBounds::conservative(),
702            ReviewRepairPolicy::default(),
703        );
704        lane.begin_iteration().expect("first iteration");
705        let stop = lane
706            .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean))
707            .expect("recorded");
708
709        assert_eq!(stop, Some(StopReason::AwaitingRatification));
710        assert!(!lane.stop_reason().expect("stopped").is_success());
711
712        let after = lane.ratify(true, "").expect("ratify a parked loop");
713        assert_eq!(after, &StopReason::Verified);
714        assert!(lane.stop_reason().expect("stopped").is_success());
715    }
716
717    #[test]
718    fn declined_ratification_is_not_success() {
719        let mut lane = loop_with(
720            ReviewRepairBounds::conservative(),
721            ReviewRepairPolicy::default(),
722        );
723        lane.begin_iteration().expect("first iteration");
724        lane.record_iteration(receipt(1, "digest-a", IterationVerdict::Clean))
725            .expect("recorded");
726
727        lane.ratify(false, "diff touches release scripts")
728            .expect("decline is a valid decision");
729        assert_eq!(
730            lane.stop_reason(),
731            Some(&StopReason::RatificationDeclined {
732                note: "diff touches release scripts".to_string()
733            })
734        );
735        assert!(!lane.stop_reason().expect("stopped").is_success());
736    }
737
738    #[test]
739    fn a_ceiling_stop_cannot_be_waved_through_by_ratification() {
740        let mut lane = loop_with(
741            ReviewRepairBounds {
742                max_iterations: 1,
743                ..ReviewRepairBounds::conservative()
744            },
745            ReviewRepairPolicy::default(),
746        );
747        lane.begin_iteration().expect("first iteration");
748        lane.record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded))
749            .expect("recorded");
750
751        let err = lane
752            .ratify(true, "looks fine to me")
753            .expect_err("there is no clean result to approve");
754        assert!(matches!(err, ReviewRepairError::AlreadyStopped(_)));
755        assert!(!lane.stop_reason().expect("stopped").is_success());
756    }
757
758    #[test]
759    fn without_the_ratification_policy_a_clean_verdict_verifies_directly() {
760        let mut lane = loop_with(
761            ReviewRepairBounds::conservative(),
762            ReviewRepairPolicy {
763                require_human_ratification: false,
764                require_verifier_receipt: true,
765            },
766        );
767        lane.begin_iteration().expect("first iteration");
768        let stop = lane
769            .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean))
770            .expect("recorded");
771        assert_eq!(stop, Some(StopReason::Verified));
772    }
773
774    #[test]
775    fn a_missing_verifier_is_inconclusive_not_a_pass() {
776        let mut lane = loop_with(
777            ReviewRepairBounds::conservative(),
778            ReviewRepairPolicy::default(),
779        );
780        lane.begin_iteration().expect("first iteration");
781        let mut r = receipt(1, "digest-a", IterationVerdict::Clean);
782        r.verifier = None;
783
784        let err = lane
785            .record_iteration(r)
786            .expect_err("review alone is not verification");
787        assert!(matches!(
788            err.stop_reason(),
789            Some(StopReason::Inconclusive { .. })
790        ));
791    }
792
793    #[test]
794    fn an_incomplete_route_is_refused_without_ending_the_loop() {
795        let mut lane = loop_with(
796            ReviewRepairBounds::conservative(),
797            ReviewRepairPolicy::default(),
798        );
799        lane.begin_iteration().expect("first iteration");
800        let mut r = receipt(1, "digest-a", IterationVerdict::Clean);
801        r.reviewer.model = "  ".to_string();
802
803        let err = lane
804            .record_iteration(r)
805            .expect_err("a route without a model is a claim");
806        assert!(matches!(err, ReviewRepairError::IncompleteReceipt(_)));
807        assert!(
808            lane.stop_reason().is_none(),
809            "the caller may retry with a real receipt"
810        );
811    }
812
813    #[test]
814    fn inconclusive_review_stops_instead_of_passing() {
815        let mut lane = loop_with(
816            ReviewRepairBounds::conservative(),
817            ReviewRepairPolicy::default(),
818        );
819        lane.begin_iteration().expect("first iteration");
820        let stop = lane
821            .record_iteration(receipt(1, "digest-a", IterationVerdict::Inconclusive))
822            .expect("recorded");
823        assert!(matches!(stop, Some(StopReason::Inconclusive { .. })));
824    }
825
826    #[test]
827    fn route_lines_show_exact_routes_and_requested_to_effective_reasoning() {
828        let mut lane = loop_with(
829            ReviewRepairBounds::conservative(),
830            ReviewRepairPolicy::default(),
831        );
832        lane.begin_iteration().expect("first iteration");
833        let mut r = receipt(1, "digest-a", IterationVerdict::Clean);
834        r.reviewer.requested_reasoning = "max".to_string();
835        r.reviewer.effective_reasoning = "high".to_string();
836        r.reviewer.routed_by = RoutedBy::Router {
837            provider: "moonshot".to_string(),
838            model: "kimi-k3".to_string(),
839        };
840        lane.record_iteration(r).expect("recorded");
841
842        let lines = lane.route_lines();
843        assert_eq!(
844            lines.len(),
845            2,
846            "reviewer and verifier both appear: {lines:?}"
847        );
848        assert!(lines[0].contains("reviewer: zhipu/glm-5.2"));
849        assert!(lines[0].contains("max→high"), "{lines:?}");
850        assert!(lines[0].contains("routed by router moonshot/kimi-k3"));
851        assert!(lines[1].contains("verifier: zhipu/glm-5.2"));
852    }
853
854    #[test]
855    fn default_policy_fails_closed() {
856        let policy = ReviewRepairPolicy::default();
857        assert!(policy.require_human_ratification);
858        assert!(policy.require_verifier_receipt);
859        let bounds = ReviewRepairBounds::default();
860        assert!(bounds.max_iterations > 0);
861        assert!(bounds.max_wall_clock_secs > 0);
862        assert!(bounds.max_tool_calls > 0);
863    }
864
865    #[test]
866    fn review_repair_defines_no_mode_or_permission_posture() {
867        // #3832 must not grow a fourth Mode or a fourth posture. This module's
868        // serialized surface is the check: a loop carries ceilings, routes, and
869        // verdicts — never a mode, posture, approval policy, or sandbox setting.
870        let lane = loop_with(
871            ReviewRepairBounds::conservative(),
872            ReviewRepairPolicy::default(),
873        );
874        let json = serde_json::to_string(&lane).expect("serialize");
875        for forbidden in [
876            "mode",
877            "posture",
878            "approval_policy",
879            "sandbox",
880            "permission",
881            "auto_review",
882            "full_access",
883        ] {
884            assert!(
885                !json.contains(forbidden),
886                "review-repair leaked {forbidden} into its own state: {json}"
887            );
888        }
889    }
890}