car_server_core/coder/heal_gate.rs
1//! The review gate for an unattended fix: N independent verdicts, a threshold.
2//!
3//! T4 of `docs/proposals/self-healing-issue-loop.md`. Pure — the verdicts are
4//! an argument. Fetching them costs inference and a network; deciding what they
5//! mean must be reproducible and reviewable, so the two are separated.
6//!
7//! ## This is the SECOND gate, never the first
8//!
9//! The first gate is the [`super::contract::OutcomeContract`], which the
10//! runtime re-runs itself before asking for merge approval regardless of which
11//! engine did the work. That is deterministic and non-negotiable: it answers
12//! "does this actually work". No count of model verdicts substitutes for it or
13//! overrides it, and [`decide`] takes the contract result as an input it can
14//! only ever *narrow*.
15//!
16//! The distinction is not theoretical. In the peer-messaging work that
17//! motivated this loop, a change shipped that made every *successful*
18//! cross-host delivery report as a failure — and every model-written test
19//! passed, because they all asserted inside the dispatcher and never
20//! round-tripped the real client. A deterministic check caught it in one run.
21//! Conversely, model review caught a change that compiled, passed every test,
22//! and would have silently refused every fleet dispatch. Neither gate subsumes
23//! the other.
24//!
25//! ## Independent verdicts, never synthesis
26//!
27//! CAR has a measured result that constrains this: the MoA / tau-bench
28//! experiment found **cross-vendor synthesis degrades agentic tool-use below
29//! the single strong model**. That was about *combining answers*, and
30//! verification is a different operation, so the result does not forbid a panel
31//! — but it is the reason this fans out to N verdicts and applies a threshold,
32//! rather than merging reviewers' reasoning into one answer.
33//!
34//! Reviewers therefore never see each other's verdicts. A panel that reaches
35//! consensus by reading itself is one reviewer with extra steps, and the
36//! failure it is meant to catch — everyone missing the same thing — is exactly
37//! the one correlation reintroduces.
38//!
39//! ## Unreachable is not approval
40//!
41//! A vendor that times out yields no verdict, and a missing verdict is never a
42//! pass. Degrading to "2 of the 2 that answered" would let an outage silently
43//! halve the panel, which is the review equivalent of turning the gate off on
44//! the day it is most needed.
45
46/// Proof that a [`GateOutcome`] came from [`decide`].
47///
48/// A private unit field is the whole mechanism: no other module can name it, so
49/// no other module can build an outcome.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct Sealed(());
52
53/// One reviewer's answer.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Verdict {
56 /// Which model produced it. Recorded so a panel that disagrees can be
57 /// audited by vendor rather than by anonymous count.
58 pub model: String,
59 pub pass: bool,
60 /// The reviewer's stated reason. Shown to humans, never parsed.
61 pub reason: String,
62}
63
64/// A reviewer that could not be reached.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Unreachable {
67 pub model: String,
68 pub error: String,
69}
70
71/// What the panel found, and what follows from it.
72///
73/// Every variant carries [`Sealed`], which only this module can construct, so
74/// [`decide`] is the **only** way to obtain a `GateOutcome`. Without that, any
75/// caller could hand back `Approved` having run no contract and asked no
76/// reviewer — and a gate that a caller can mint is not a gate. The first draft
77/// of this had exactly that hole: the tick's injected `run_coder` returned a
78/// `GateOutcome` directly and `decide` had no callers at all.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum GateOutcome {
81 /// Deterministic checks passed and the panel cleared it.
82 Approved {
83 passes: usize,
84 of: usize,
85 seal: Sealed,
86 },
87 /// The deterministic gate failed. The panel is not consulted at all: a
88 /// change that does not build is not a question for reviewers.
89 ContractFailed { detail: String, seal: Sealed },
90 /// Reviewers were reached but did not clear it.
91 PanelRejected {
92 passes: usize,
93 required: usize,
94 dissent: Vec<Verdict>,
95 seal: Sealed,
96 },
97 /// Too few reviewers answered to reach a threshold at all.
98 PanelIncomplete {
99 answered: usize,
100 required: usize,
101 unreachable: Vec<Unreachable>,
102 seal: Sealed,
103 },
104}
105
106impl GateOutcome {
107 pub fn approved(&self) -> bool {
108 matches!(self, GateOutcome::Approved { .. })
109 }
110
111 /// One line an operator can read without opening the run.
112 pub fn summary(&self) -> String {
113 match self {
114 GateOutcome::Approved { passes, of, .. } => {
115 format!("approved by {passes}/{of} reviewers, checks green")
116 }
117 GateOutcome::ContractFailed { detail, .. } => {
118 format!("outcome contract failed: {detail}")
119 }
120 GateOutcome::PanelRejected {
121 passes,
122 required,
123 dissent,
124 ..
125 } => {
126 let why = dissent
127 .iter()
128 .map(|v| format!("{}: {}", v.model, v.reason))
129 .collect::<Vec<_>>()
130 .join("; ");
131 format!("{passes}/{required} required approvals — {why}")
132 }
133 GateOutcome::PanelIncomplete {
134 answered,
135 required,
136 unreachable,
137 ..
138 } => {
139 let who = unreachable
140 .iter()
141 .map(|u| format!("{} ({})", u.model, u.error))
142 .collect::<Vec<_>>()
143 .join("; ");
144 format!(
145 "only {answered} reviewers answered, {required} approvals required — \
146 unreachable: {who}"
147 )
148 }
149 }
150 }
151}
152
153/// How many approvals a panel of `panel_size` needs.
154///
155/// A strict majority, so a panel can never be cleared by a minority and a
156/// single compromised or malfunctioning reviewer cannot approve alone. For the
157/// default panel of three this is two.
158pub fn required_approvals(panel_size: usize) -> usize {
159 panel_size / 2 + 1
160}
161
162/// Decide, from the deterministic result and the reviewers' answers.
163///
164/// `panel_size` is how many reviewers were *asked*, not how many replied — the
165/// threshold is fixed before the vote, so an outage cannot lower the bar it has
166/// to clear.
167pub fn decide(
168 contract_passed: bool,
169 contract_detail: &str,
170 panel_size: usize,
171 verdicts: &[Verdict],
172 unreachable: &[Unreachable],
173) -> GateOutcome {
174 // Deterministic first, and reviewers are not consulted on a red build.
175 // Asking a model to bless code that does not compile invites it to explain
176 // why the failure is acceptable, which is a conversation with only one bad
177 // outcome.
178 if !contract_passed {
179 return GateOutcome::ContractFailed {
180 detail: contract_detail.to_string(),
181 seal: Sealed(()),
182 };
183 }
184
185 if panel_size == 0 {
186 return GateOutcome::ContractFailed {
187 detail: "no reviewers were configured; a panel of zero cannot approve".into(),
188 seal: Sealed(()),
189 };
190 }
191
192 // ONE answer per model. `Verdict::model` is recorded so a panel can be
193 // audited by vendor rather than by anonymous count — and then counting
194 // anonymously would let one vendor, retried after a timeout and appended
195 // twice, supply a majority by itself. First answer per model wins; a
196 // retry does not get a second vote.
197 let mut seen: Vec<&str> = Vec::new();
198 let mut answers: Vec<Verdict> = Vec::new();
199 for v in verdicts {
200 if seen.contains(&v.model.as_str()) {
201 continue;
202 }
203 seen.push(&v.model);
204 answers.push(v.clone());
205 }
206 // More distinct answers than reviewers asked means the caller is confused
207 // about its own panel; refusing is safer than picking an interpretation.
208 if answers.len() > panel_size {
209 return GateOutcome::ContractFailed {
210 detail: format!(
211 "{} distinct verdicts for a panel of {panel_size}",
212 answers.len()
213 ),
214 seal: Sealed(()),
215 };
216 }
217
218 let required = required_approvals(panel_size);
219
220 // A missing verdict is not a pass. If too few answered to reach the
221 // threshold even unanimously, that is incomplete rather than rejected —
222 // the distinction matters because the remedy differs: retry the panel
223 // versus fix the change.
224 if answers.len() < required {
225 return GateOutcome::PanelIncomplete {
226 answered: answers.len(),
227 required,
228 unreachable: unreachable.to_vec(),
229 seal: Sealed(()),
230 };
231 }
232
233 let passes = answers.iter().filter(|v| v.pass).count();
234 if passes >= required {
235 return GateOutcome::Approved {
236 passes,
237 of: panel_size,
238 seal: Sealed(()),
239 };
240 }
241
242 GateOutcome::PanelRejected {
243 passes,
244 required,
245 dissent: answers.iter().filter(|v| !v.pass).cloned().collect(),
246 seal: Sealed(()),
247 }
248}
249
250/// The criteria one reviewer is asked to judge against.
251///
252/// Written once, here, so every reviewer in a panel is asked the *same*
253/// question — a panel whose members were prompted differently is measuring
254/// prompt variance, not agreement.
255///
256/// It deliberately does not include the issue body verbatim. The body is
257/// untrusted text (see [`super::provenance`]), and a reviewer prompt is exactly
258/// the place where "ignore the above" would land. Reviewers judge the DIFF
259/// against the stated intent and the deterministic results.
260pub fn review_criteria(intent_summary: &str, contract_detail: &str) -> String {
261 format!(
262 "You are reviewing an automated code change before it is opened as a pull request.\n\
263 \n\
264 Stated intent: {intent_summary}\n\
265 Deterministic checks: {contract_detail}\n\
266 \n\
267 The build and the project's own acceptance checks have ALREADY passed. Do not \
268 re-litigate them. Judge only what they cannot:\n\
269 - Does the change do what the intent says, or something adjacent to it?\n\
270 - Does it introduce a defect the checks would not catch?\n\
271 - Is it scoped to the intent, or does it change unrelated behaviour?\n\
272 \n\
273 Answer PASS or FAIL and one sentence of reason. Default to FAIL if you are \
274 uncertain: a rejected change costs one retry, an approved bad one costs a human's \
275 trust in every later change."
276 )
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn v(model: &str, pass: bool) -> Verdict {
284 Verdict {
285 model: model.into(),
286 pass,
287 reason: if pass { "looks right" } else { "wrong scope" }.into(),
288 }
289 }
290
291 #[test]
292 fn a_red_contract_is_never_put_to_the_panel() {
293 // Even with a unanimous panel: a change that does not build is not a
294 // question reviewers should be asked.
295 let out = decide(
296 false,
297 "cargo test failed",
298 3,
299 &[v("a", true), v("b", true), v("c", true)],
300 &[],
301 );
302 assert!(matches!(out, GateOutcome::ContractFailed { .. }));
303 assert!(!out.approved());
304 }
305
306 #[test]
307 fn a_strict_majority_approves() {
308 let out = decide(
309 true,
310 "green",
311 3,
312 &[v("a", true), v("b", true), v("c", false)],
313 &[],
314 );
315 assert!(matches!(
316 out,
317 GateOutcome::Approved {
318 passes: 2,
319 of: 3,
320 ..
321 }
322 ));
323 }
324
325 #[test]
326 fn a_minority_does_not_approve() {
327 let out = decide(
328 true,
329 "green",
330 3,
331 &[v("a", true), v("b", false), v("c", false)],
332 &[],
333 );
334 match out {
335 GateOutcome::PanelRejected {
336 passes,
337 required,
338 dissent,
339 ..
340 } => {
341 assert_eq!((passes, required), (1, 2));
342 assert_eq!(dissent.len(), 2, "dissent is recorded, not just counted");
343 }
344 other => panic!("expected rejection, got {other:?}"),
345 }
346 }
347
348 #[test]
349 fn an_unreachable_vendor_cannot_halve_the_panel() {
350 // The threshold is fixed before the vote. Two of three answering
351 // unanimously is NOT two of two.
352 let out = decide(
353 true,
354 "green",
355 3,
356 &[v("a", true)],
357 &[Unreachable {
358 model: "b".into(),
359 error: "timeout".into(),
360 }],
361 );
362 match out {
363 GateOutcome::PanelIncomplete {
364 answered, required, ..
365 } => assert_eq!((answered, required), (1, 2)),
366 other => panic!("expected incomplete, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn exactly_enough_answers_can_still_approve() {
372 let out = decide(true, "green", 3, &[v("a", true), v("b", true)], &[]);
373 assert!(matches!(
374 out,
375 GateOutcome::Approved {
376 passes: 2,
377 of: 3,
378 ..
379 }
380 ));
381 }
382
383 #[test]
384 fn exactly_enough_answers_can_also_reject() {
385 let out = decide(true, "green", 3, &[v("a", true), v("b", false)], &[]);
386 assert!(matches!(out, GateOutcome::PanelRejected { .. }));
387 }
388
389 #[test]
390 fn a_single_reviewer_cannot_approve_alone_on_a_panel_of_three() {
391 assert_eq!(required_approvals(3), 2);
392 assert_eq!(required_approvals(5), 3);
393 // A panel of one is a strict majority of itself — degenerate, and the
394 // caller's problem to avoid, but the arithmetic must not surprise.
395 assert_eq!(required_approvals(1), 1);
396 }
397
398 #[test]
399 fn incomplete_and_rejected_are_different_outcomes() {
400 // The remedy differs: retry the panel versus fix the change. Collapsing
401 // them would send an operator to debug code when a vendor was down.
402 let incomplete = decide(true, "green", 3, &[], &[]);
403 let rejected = decide(true, "green", 3, &[v("a", false), v("b", false)], &[]);
404 assert!(matches!(incomplete, GateOutcome::PanelIncomplete { .. }));
405 assert!(matches!(rejected, GateOutcome::PanelRejected { .. }));
406 }
407
408 #[test]
409 fn the_summary_names_the_dissenters_and_the_unreachable() {
410 let rejected = decide(true, "green", 3, &[v("a", true), v("b", false)], &[]);
411 assert!(rejected.summary().contains('b'), "{}", rejected.summary());
412
413 let incomplete = decide(
414 true,
415 "green",
416 3,
417 &[],
418 &[Unreachable {
419 model: "gpt".into(),
420 error: "429".into(),
421 }],
422 );
423 assert!(incomplete.summary().contains("gpt"));
424 assert!(incomplete.summary().contains("429"));
425 }
426
427 #[test]
428 fn the_criteria_do_not_carry_the_issue_body() {
429 // A reviewer prompt is exactly where "ignore the above" would land, so
430 // untrusted tracker text must not reach it.
431 let c = review_criteria("fix the off-by-one in the parser", "all checks green");
432 assert!(c.contains("off-by-one"));
433 assert!(
434 c.contains("Default to FAIL"),
435 "uncertainty must not read as approval"
436 );
437 }
438
439 #[test]
440 fn every_reviewer_is_asked_the_same_question() {
441 let a = review_criteria("intent", "green");
442 let b = review_criteria("intent", "green");
443 assert_eq!(
444 a, b,
445 "a panel prompted differently measures prompt variance"
446 );
447 }
448}