Skip to main content

car_multi/patterns/foreman/
report.rs

1//! The serializable wire contract for the Foreman pipeline (B6 wire-freeze).
2//!
3//! The internal gate/harness/planner types stay free to evolve; THESE versioned
4//! DTOs are the stable shape the daemon JSON-RPC and the four FFI binding
5//! surfaces expose. Conversions are one-way (internal → DTO).
6//!
7//! Wire-freeze hygiene (from the B5 review), so this can be frozen at release:
8//! - **String discriminants** on every enum (`snake_case` serde tags), never
9//!   positional/integer — adding a variant can't shift another's meaning.
10//! - A **`schema_version`** on the top-level report.
11//! - **Structured** rejection evidence (typed containment / duplicate / build
12//!   fields), not flattened prose — this is what a future post-gate replan reads.
13//! - A forward-compatible **`unknown`** escape variant on each externally-matched
14//!   enum, so an older client doesn't hard-fail on a newer server's state.
15//! - The scheduler's **`ExpandedFootprint` is never representable here** — only
16//!   the gate's verdict and declared `(file, symbol)` refs — so "scheduler output
17//!   never feeds acceptance" holds on the wire, not just in Rust.
18
19use serde::{Deserialize, Serialize};
20
21use super::gate::{AcceptanceBasis, BuildTestStatus, GateEvidence, MergeVerdict, PolicyDecision};
22use super::harness::{IntegrationBlame, IntegrationResult, Subtask, SubtaskOutcome};
23use super::planner::DecomposeResult;
24
25/// Bump when the serialized shape changes incompatibly.
26pub const FOREMAN_SCHEMA_VERSION: u32 = 1;
27
28/// A `(file, symbol)` location on the wire.
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30pub struct SymbolRefDto {
31    pub file: String,
32    pub symbol: String,
33}
34
35/// A duplicate-declaration finding on the wire.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct DuplicateDto {
38    pub file: String,
39    pub symbol: String,
40    pub kind: String,
41    pub count: usize,
42}
43
44/// The build/test leg outcome.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(tag = "status", rename_all = "snake_case")]
47pub enum BuildTestDto {
48    NotConfigured,
49    NotRun {
50        reason: String,
51    },
52    Passed,
53    /// `output` is the bounded stdout+stderr tail — a coding orchestrator's user
54    /// (and a replan loop) needs the *why*, not just an exit code.
55    Failed {
56        code: Option<i32>,
57        output: String,
58    },
59    #[serde(other)]
60    Unknown,
61}
62
63/// Why a worktree was accepted.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(tag = "kind", rename_all = "snake_case")]
66pub enum AcceptanceBasisDto {
67    Verified,
68    Waived {
69        class: String,
70        reason: String,
71    },
72    #[serde(other)]
73    Unknown,
74}
75
76/// The gate's evidence — structured, so a consumer (or replan) can attribute a
77/// verdict without parsing prose.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct GateEvidenceDto {
80    pub subtask: String,
81    pub changed_symbol_count: usize,
82    pub footprint_declared: bool,
83    pub containment_violations: Vec<SymbolRefDto>,
84    pub unparsed_changed_files: Vec<String>,
85    pub semantic_conflicts: Vec<DuplicateDto>,
86    pub build_test: BuildTestDto,
87    /// `None` = policy allowed; `Some(reasons)` = denied (reasons preserved,
88    /// symmetric with the verdict's own `reasons`).
89    pub policy_denied: Option<Vec<String>>,
90}
91
92/// The gate's verdict on a worktree (per-subtask or the integrated union).
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "outcome", rename_all = "snake_case")]
95pub enum MergeVerdictDto {
96    Accepted {
97        basis: AcceptanceBasisDto,
98        evidence: GateEvidenceDto,
99    },
100    Rejected {
101        reasons: Vec<String>,
102        evidence: GateEvidenceDto,
103    },
104    Inconclusive {
105        reasons: Vec<String>,
106        evidence: GateEvidenceDto,
107    },
108    /// A verdict outcome this version doesn't recognize (forward-compat). It is
109    /// **lossy** — the unrecognized payload is not preserved — and **must** be
110    /// treated as non-accepting by every consumer. Use [`is_accepting`] rather
111    /// than matching, so an unknown future state can never be merged.
112    ///
113    /// [`is_accepting`]: MergeVerdictDto::is_accepting
114    #[serde(other)]
115    Unknown,
116}
117
118impl MergeVerdictDto {
119    /// Fail-closed acceptance predicate: ONLY `Accepted` accepts. Every other
120    /// state — Rejected, Inconclusive, and the forward-compat `Unknown` —
121    /// returns `false`, so a consumer gating on this can never merge an
122    /// unrecognized verdict.
123    pub fn is_accepting(&self) -> bool {
124        matches!(self, MergeVerdictDto::Accepted { .. })
125    }
126}
127
128/// One farmed-out subtask's outcome.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SubtaskReportDto {
131    pub id: String,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub verdict: Option<MergeVerdictDto>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub error: Option<String>,
136}
137
138/// A patch that failed to apply, attributed to its subtask + targeted files.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct ApplyConflictDto {
141    pub subtask_id: String,
142    pub files: Vec<String>,
143    pub detail: String,
144}
145
146/// A union duplicate declaration. `candidate_subtask_ids` are the subtasks whose
147/// patches touched the offending *file* — candidates, not proven culprits (file
148/// granularity can implicate a subtask that edited the file but not the symbol).
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct DuplicateBlameDto {
151    pub file: String,
152    pub symbol: String,
153    pub candidate_subtask_ids: Vec<String>,
154}
155
156/// The union build/test failure that rejected the merge. `candidate_subtask_ids`
157/// is the whole integrated set — a build failure can't be localized further
158/// without mapping test output back to a symbol (not done).
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct BuildTestFailureDto {
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub code: Option<i32>,
163    pub output_tail: String,
164    pub candidate_subtask_ids: Vec<String>,
165}
166
167/// Structured attribution of *why* a union integration failed — the wire form of
168/// the harness `IntegrationBlame`. What a post-gate regional replan reads to retry
169/// the failing region, and what a UI reads to show "why did this run fail".
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct IntegrationBlameDto {
172    pub apply_conflicts: Vec<ApplyConflictDto>,
173    pub duplicate_conflicts: Vec<DuplicateBlameDto>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub build_test: Option<BuildTestFailureDto>,
176}
177
178/// The integrated-union outcome.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct IntegrationReportDto {
181    pub applied: usize,
182    pub apply_conflicts: Vec<String>,
183    pub integrated_cleanly: bool,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub verdict: Option<MergeVerdictDto>,
186    /// Structured failure attribution — present only when the union did not
187    /// integrate cleanly.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub blame: Option<IntegrationBlameDto>,
190}
191
192/// The full report of a Foreman run.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct ForemanReport {
195    pub schema_version: u32,
196    pub subtasks: Vec<SubtaskReportDto>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub integration: Option<IntegrationReportDto>,
199}
200
201impl ForemanReport {
202    /// Build the wire report from a farm-out's outcomes and (optional)
203    /// integration result.
204    pub fn from_run(outcomes: &[SubtaskOutcome], integration: Option<&IntegrationResult>) -> Self {
205        Self {
206            schema_version: FOREMAN_SCHEMA_VERSION,
207            subtasks: outcomes.iter().map(SubtaskReportDto::from).collect(),
208            integration: integration.map(IntegrationReportDto::from),
209        }
210    }
211}
212
213/// One planned subtask on the wire — carries only the DECLARED `(file, symbol)`
214/// footprint, never the scheduler's expanded blast radius.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct PlanSubtaskDto {
217    pub id: String,
218    pub prompt: String,
219    pub files: Vec<String>,
220    pub writes: Vec<SymbolRefDto>,
221    pub reads: Vec<SymbolRefDto>,
222}
223
224/// The decomposition plan — the SCHEDULER surface, deliberately distinct from
225/// [`MergeVerdictDto`] (the gate surface) so a consumer can never mistake a
226/// scheduling hint (`levels`, `prefer_single_session`) for a safety verdict.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct PlanReport {
229    pub schema_version: u32,
230    /// `true` when a valid plan was produced (parsed + no declared conflicts).
231    pub valid: bool,
232    /// Advisory: farming out buys no parallel speedup — prefer a single session.
233    pub prefer_single_session: bool,
234    pub attempts: u32,
235    /// Parse / decomposition issues (empty when `valid`).
236    pub issues: Vec<String>,
237    /// Scheduled levels (subtask ids) — a SCHEDULING hint, not a verdict.
238    pub levels: Vec<Vec<String>>,
239    pub subtasks: Vec<PlanSubtaskDto>,
240}
241
242impl From<&DecomposeResult> for PlanReport {
243    fn from(r: &DecomposeResult) -> Self {
244        Self {
245            schema_version: FOREMAN_SCHEMA_VERSION,
246            valid: r.is_valid(),
247            prefer_single_session: r.prefer_single_session,
248            attempts: r.attempts,
249            issues: r.issues.clone(),
250            levels: r.levels.clone(),
251            subtasks: r.subtasks.iter().map(PlanSubtaskDto::from).collect(),
252        }
253    }
254}
255
256impl From<&Subtask> for PlanSubtaskDto {
257    fn from(s: &Subtask) -> Self {
258        // Sort symbol refs for deterministic wire output (footprint sets are
259        // unordered). Declared footprint only — the expanded set is never here.
260        let (mut writes, mut reads): (Vec<SymbolRefDto>, Vec<SymbolRefDto>) = match &s.footprint {
261            Some(fp) => (
262                fp.writes
263                    .iter()
264                    .map(|r| SymbolRefDto {
265                        file: r.file.clone(),
266                        symbol: r.symbol.clone(),
267                    })
268                    .collect(),
269                fp.reads
270                    .iter()
271                    .map(|r| SymbolRefDto {
272                        file: r.file.clone(),
273                        symbol: r.symbol.clone(),
274                    })
275                    .collect(),
276            ),
277            None => (Vec::new(), Vec::new()),
278        };
279        writes.sort();
280        reads.sort();
281        Self {
282            id: s.id.clone(),
283            prompt: s.prompt.clone(),
284            files: s.files.clone(),
285            writes,
286            reads,
287        }
288    }
289}
290
291// ---- one-way conversions (internal → DTO) ----
292
293impl From<&BuildTestStatus> for BuildTestDto {
294    fn from(s: &BuildTestStatus) -> Self {
295        match s {
296            BuildTestStatus::NotConfigured => BuildTestDto::NotConfigured,
297            BuildTestStatus::NotRun { reason } => BuildTestDto::NotRun {
298                reason: reason.clone(),
299            },
300            BuildTestStatus::Passed => BuildTestDto::Passed,
301            BuildTestStatus::Failed { code, output } => BuildTestDto::Failed {
302                code: *code,
303                output: output.clone(),
304            },
305        }
306    }
307}
308
309impl From<&AcceptanceBasis> for AcceptanceBasisDto {
310    fn from(b: &AcceptanceBasis) -> Self {
311        match b {
312            AcceptanceBasis::Verified => AcceptanceBasisDto::Verified,
313            AcceptanceBasis::Waived { class, reason } => AcceptanceBasisDto::Waived {
314                class: class.clone(),
315                reason: reason.clone(),
316            },
317        }
318    }
319}
320
321impl From<&GateEvidence> for GateEvidenceDto {
322    fn from(e: &GateEvidence) -> Self {
323        // Destructure so a new field on GateEvidence is a COMPILE ERROR here, not
324        // a silent omission from the frozen wire shape. Adding a field becomes a
325        // deliberate decision: wire it, or `_`-ignore it on purpose.
326        let GateEvidence {
327            subtask,
328            changed_symbols,
329            footprint_declared,
330            containment: _, // a CheckOutcome summary; the violations carry the detail
331            containment_violations,
332            unparsed_changed_files,
333            duplicates: _, // a CheckOutcome summary; semantic_conflicts carry the detail
334            semantic_conflicts,
335            build_test,
336            policy,
337        } = e;
338        Self {
339            subtask: subtask.clone(),
340            changed_symbol_count: changed_symbols.len(),
341            footprint_declared: *footprint_declared,
342            containment_violations: containment_violations
343                .iter()
344                .map(|v| SymbolRefDto {
345                    file: v.changed.file.clone(),
346                    symbol: v.changed.symbol.clone(),
347                })
348                .collect(),
349            unparsed_changed_files: unparsed_changed_files.clone(),
350            semantic_conflicts: semantic_conflicts
351                .iter()
352                .map(|d| DuplicateDto {
353                    file: d.file.clone(),
354                    symbol: d.symbol.clone(),
355                    kind: d.kind.clone(),
356                    count: d.count,
357                })
358                .collect(),
359            build_test: BuildTestDto::from(build_test),
360            policy_denied: match policy {
361                PolicyDecision::Deny { reasons } => Some(reasons.clone()),
362                PolicyDecision::Allow => None,
363            },
364        }
365    }
366}
367
368impl From<&MergeVerdict> for MergeVerdictDto {
369    fn from(v: &MergeVerdict) -> Self {
370        match v {
371            MergeVerdict::Accepted { basis, evidence } => MergeVerdictDto::Accepted {
372                basis: AcceptanceBasisDto::from(basis),
373                evidence: GateEvidenceDto::from(evidence),
374            },
375            MergeVerdict::Rejected { reasons, evidence } => MergeVerdictDto::Rejected {
376                reasons: reasons.clone(),
377                evidence: GateEvidenceDto::from(evidence),
378            },
379            MergeVerdict::Inconclusive { reasons, evidence } => MergeVerdictDto::Inconclusive {
380                reasons: reasons.clone(),
381                evidence: GateEvidenceDto::from(evidence),
382            },
383        }
384    }
385}
386
387impl From<&SubtaskOutcome> for SubtaskReportDto {
388    fn from(o: &SubtaskOutcome) -> Self {
389        Self {
390            id: o.subtask_id.clone(),
391            verdict: o.verdict.as_ref().map(MergeVerdictDto::from),
392            error: o.error.clone(),
393        }
394    }
395}
396
397// Destructure each source struct (no `..`) so a future field addition is a
398// compile error here — a silently-dropped wire field is the failure mode this
399// pattern exists to prevent (matches `GateEvidenceDto::from`).
400impl From<&super::harness::ApplyConflict> for ApplyConflictDto {
401    fn from(c: &super::harness::ApplyConflict) -> Self {
402        let super::harness::ApplyConflict {
403            subtask_id,
404            files,
405            detail,
406        } = c;
407        Self {
408            subtask_id: subtask_id.clone(),
409            files: files.clone(),
410            detail: detail.clone(),
411        }
412    }
413}
414
415impl From<&super::harness::DuplicateBlame> for DuplicateBlameDto {
416    fn from(d: &super::harness::DuplicateBlame) -> Self {
417        let super::harness::DuplicateBlame {
418            file,
419            symbol,
420            candidate_subtask_ids,
421        } = d;
422        Self {
423            file: file.clone(),
424            symbol: symbol.clone(),
425            candidate_subtask_ids: candidate_subtask_ids.clone(),
426        }
427    }
428}
429
430impl From<&super::harness::BuildTestFailure> for BuildTestFailureDto {
431    fn from(f: &super::harness::BuildTestFailure) -> Self {
432        let super::harness::BuildTestFailure {
433            code,
434            output_tail,
435            candidate_subtask_ids,
436        } = f;
437        Self {
438            code: *code,
439            output_tail: output_tail.clone(),
440            candidate_subtask_ids: candidate_subtask_ids.clone(),
441        }
442    }
443}
444
445impl From<&IntegrationBlame> for IntegrationBlameDto {
446    fn from(b: &IntegrationBlame) -> Self {
447        let IntegrationBlame {
448            apply_conflicts,
449            duplicate_conflicts,
450            build_test,
451        } = b;
452        Self {
453            apply_conflicts: apply_conflicts.iter().map(ApplyConflictDto::from).collect(),
454            duplicate_conflicts: duplicate_conflicts
455                .iter()
456                .map(DuplicateBlameDto::from)
457                .collect(),
458            build_test: build_test.as_ref().map(BuildTestFailureDto::from),
459        }
460    }
461}
462
463impl From<&IntegrationResult> for IntegrationReportDto {
464    fn from(r: &IntegrationResult) -> Self {
465        Self {
466            applied: r.applied,
467            apply_conflicts: r.apply_conflicts.clone(),
468            integrated_cleanly: r.integrated_cleanly(),
469            verdict: r.verdict.as_ref().map(MergeVerdictDto::from),
470            blame: r.blame.as_ref().map(IntegrationBlameDto::from),
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn evidence() -> GateEvidenceDto {
480        GateEvidenceDto {
481            subtask: "a".into(),
482            changed_symbol_count: 1,
483            footprint_declared: true,
484            containment_violations: vec![],
485            unparsed_changed_files: vec![],
486            semantic_conflicts: vec![],
487            build_test: BuildTestDto::Passed,
488            policy_denied: None,
489        }
490    }
491
492    #[test]
493    fn verdict_uses_stable_string_discriminants() {
494        let v = MergeVerdictDto::Accepted {
495            basis: AcceptanceBasisDto::Verified,
496            evidence: evidence(),
497        };
498        let json = serde_json::to_value(&v).unwrap();
499        assert_eq!(json["outcome"], "accepted");
500        assert_eq!(json["basis"]["kind"], "verified");
501        assert_eq!(json["evidence"]["build_test"]["status"], "passed");
502    }
503
504    #[test]
505    fn report_carries_schema_version_and_round_trips() {
506        let report = ForemanReport {
507            schema_version: FOREMAN_SCHEMA_VERSION,
508            subtasks: vec![SubtaskReportDto {
509                id: "a".into(),
510                verdict: Some(MergeVerdictDto::Rejected {
511                    reasons: vec!["build/test failed".into()],
512                    evidence: evidence(),
513                }),
514                error: None,
515            }],
516            integration: Some(IntegrationReportDto {
517                applied: 1,
518                apply_conflicts: vec![],
519                integrated_cleanly: true,
520                verdict: None,
521                blame: None,
522            }),
523        };
524        let json = serde_json::to_string(&report).unwrap();
525        assert!(json.contains("\"schema_version\":1"));
526        let back: ForemanReport = serde_json::from_str(&json).unwrap();
527        assert_eq!(report, back, "DTO round-trips losslessly");
528    }
529
530    #[test]
531    fn failure_output_and_policy_reasons_survive_the_wire() {
532        let mut ev = evidence();
533        ev.build_test = BuildTestDto::Failed {
534            code: Some(101),
535            output: "error[E0308]: mismatched types".into(),
536        };
537        ev.policy_denied = Some(vec!["protected path".into()]);
538        let json = serde_json::to_value(&ev).unwrap();
539        assert_eq!(
540            json["build_test"]["output"],
541            "error[E0308]: mismatched types"
542        );
543        assert_eq!(json["policy_denied"][0], "protected path");
544    }
545
546    #[test]
547    fn is_accepting_is_fail_closed() {
548        let accepted = MergeVerdictDto::Accepted {
549            basis: AcceptanceBasisDto::Verified,
550            evidence: evidence(),
551        };
552        assert!(accepted.is_accepting());
553        assert!(!MergeVerdictDto::Unknown.is_accepting());
554        assert!(!MergeVerdictDto::Inconclusive {
555            reasons: vec![],
556            evidence: evidence()
557        }
558        .is_accepting());
559    }
560
561    #[test]
562    fn unknown_variant_is_forward_compatible() {
563        // A future server emits an outcome this client doesn't know.
564        let json = r#"{"outcome":"quarantined","evidence":{}}"#;
565        let v: MergeVerdictDto = serde_json::from_str(json).unwrap();
566        assert_eq!(
567            v,
568            MergeVerdictDto::Unknown,
569            "unknown outcome degrades, not panics"
570        );
571
572        let bt: BuildTestDto = serde_json::from_str(r#"{"status":"sandboxed"}"#).unwrap();
573        assert_eq!(bt, BuildTestDto::Unknown);
574    }
575}