Skip to main content

octl_core/
report.rs

1//! §7.3 terminal-report payload validation (design.md §7.3).
2//!
3//! Validates the structural shape of a `node.report` payload — `success`
4//! required; optional `summary`, `cancelled`/`reason`, `discussion_items`,
5//! `spinoff_proposals`, `wrap_up_recommendations` — before the reducer
6//! ever projects it. Lives in `octl-core` (not the CLI) so the supervisor
7//! can validate child reports with the same rules it would consume
8//! (design.md §7.3 step 3), rather than copying the validator or depending
9//! on the CLI crate.
10//!
11//! Errors are domain-typed ([`ReportValidationError`]); the CLI maps them
12//! to its `CliError` envelope at the boundary.
13//!
14//! # Two validation modes
15//!
16//! - **Strict** ([`validate_report_payload`]) — the whole payload passes or
17//!   the first schema violation is returned. Used by `node report` (an agent
18//!   self-submission) and merge-recovery's synthesized report, where the caller
19//!   controls the shape and a malformed field is a real bug to surface.
20//! - **Lenient advisory** ([`sanitize_report_advisory`]) — the REQUIRED and
21//!   correctness-bearing fields (`success`, `cancelled`/`reason`) are still
22//!   validated strictly, but the *advisory* sections (`summary`,
23//!   `discussion_items`, `spinoff_proposals`, `wrap_up_recommendations`) degrade
24//!   gracefully: a malformed element is dropped and reported as a machine-readable
25//!   [`AdvisoryWarning`] rather than rejecting the whole payload. Used by
26//!   `run merge --report-file` so an advisory-field typo can no longer block a
27//!   clean, already-committed code merge (issue `merge-report-schema-lenience`).
28
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32use crate::schema::Kind;
33
34/// The report-payload key under which a typed [`ReportOrigin`] is serialized
35/// (issue `typed-report-origin`).
36pub const REPORT_ORIGIN_KEY: &str = "origin";
37
38/// The legacy `via` marker `run merge` stamps on the terminal `node.report` it
39/// appends after a clean merge, alongside the typed [`ReportOrigin::RunMerge`].
40/// This is the octl-cli/octl-core contract point: the CLI
41/// (`crates/octl-cli/src/run/merge.rs`) writes it, and — for a legacy on-disk
42/// report carrying NO `origin` field — [`ReportOrigin::report_is_confirmed_merge`]
43/// reads it as the fallback merge signal. Retained for backward compatibility with
44/// pre-typed-origin runs and downgrade-reading older CLIs; the typed origin is the
45/// authority for a report that carries one (issue `retire-via-string`). Lives here
46/// beside [`REPORT_ORIGIN_KEY`] as a wire-protocol constant, and is re-exported at
47/// the crate root (`octl_core::VIA_EXPLICIT_MERGE`) for the CLI writers.
48pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
49
50/// The typed provenance of a `node.report` — WHO authored it (issue
51/// `typed-report-origin`).
52///
53/// Before this field, `supervise::outcome` split a terminal report's outcome by
54/// sniffing string conventions: a `reason` that `starts_with("agent-")` or is one
55/// of a hard-coded set meant "supervisor failure", and `via: "explicit-merge"`
56/// from *any* author meant "merged". Those conventions are brittle (a new
57/// supervisor reason silently misclassifies) and conflate the report's AUTHOR with
58/// its content. `ReportOrigin` records the author explicitly on the event, so the
59/// outcome table can read a typed fact instead of pattern-matching prose.
60///
61/// The origin is stamped by the code path that appends the report, never accepted
62/// from an untrusted payload: `run merge` stamps [`ReportOrigin::RunMerge`] (the
63/// SOLE merge authority — an agent's `node report` cannot assert it; that path
64/// normalizes any supplied origin back to [`ReportOrigin::Agent`]), the supervisor
65/// stamps [`ReportOrigin::Supervisor`] on every report it synthesizes, and an
66/// agent self-submission is [`ReportOrigin::Agent`]. This keeps merge authorization
67/// tied to the run-merge path exactly as the legacy `via` marker did — the typed
68/// origin is a parallel, higher-fidelity signal, not a new trust boundary.
69///
70/// Serialized under [`REPORT_ORIGIN_KEY`] with an internal `kind` tag, e.g.
71/// `{"kind": "agent"}`, `{"kind": "supervisor"}`,
72/// `{"kind": "run-merge", "op_id": "…", "worker_oid": "…"}`.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "kind", rename_all = "kebab-case")]
75pub enum ReportOrigin {
76    /// The worker agent authored this report itself — a `node report`
77    /// self-submission (a success handoff, or a blocked `success: false` handoff).
78    Agent,
79    /// The supervisor/launcher synthesized this report: a told worker-exit
80    /// failure, the crash backstop, or a re-spawn-exhausted failure.
81    Supervisor,
82    /// Stamped by the `run merge` transaction (or its crash recovery) — the ONLY
83    /// authority for a merge/success outcome. The immutable transaction OIDs are
84    /// carried for provenance/forensics; they are absent only on the legacy
85    /// unguarded merge path (no concrete source branch / stubbed git) where no
86    /// transaction was recorded, but the discriminant alone still identifies the
87    /// report as a genuine `run merge`.
88    RunMerge {
89        /// The merge transaction's `op_id`, when a transaction was recorded.
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        op_id: Option<String>,
92        /// The worker tip OID the merge integrated, when a transaction was recorded.
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        worker_oid: Option<String>,
95    },
96}
97
98impl ReportOrigin {
99    /// Read the typed origin from a report payload, returning `None` for BOTH an
100    /// absent `origin` field (a legacy report written before this field existed)
101    /// AND a present-but-malformed one (corrupt / hand-edited / a future variant).
102    ///
103    /// IMPORTANT — `None` does NOT mean "fall back to the legacy `via`/`reason`
104    /// string path". That fallback is gated on the `origin` KEY being genuinely
105    /// ABSENT (`report.get(REPORT_ORIGIN_KEY).is_none()`), NOT on this returning
106    /// `None`. A present-but-malformed origin is "typed, but unknown authority":
107    /// it must NEVER re-unlock the forgeable legacy path (a merge on a forged
108    /// `via`, a supervisor failure on a spoofed `reason`). Every consumer
109    /// (`report_is_confirmed_merge`, `supervise::outcome::classify` /
110    /// `is_supervisor_failure`) checks key presence separately for exactly this
111    /// reason — do not collapse the two. See `report_is_confirmed_merge`.
112    #[must_use]
113    pub fn from_report(report: &Value) -> Option<Self> {
114        let raw = report.get(REPORT_ORIGIN_KEY)?;
115        serde_json::from_value(raw.clone()).ok()
116    }
117
118    /// True when a terminal `node.report` payload is a CONFIRMED, SUCCESSFUL
119    /// `run merge` — the sole authority for a merge/success outcome (issue
120    /// `retire-via-string`).
121    ///
122    /// The typed [`ReportOrigin::RunMerge`] (stamped only by the `run merge`
123    /// transaction / its crash recovery — an agent's `node report` is normalized
124    /// to [`ReportOrigin::Agent`]) is the authoritative marker. The legacy
125    /// `via: "explicit-merge"` string is honored ONLY as a fallback for a report
126    /// that carries NO `origin` field at all — a legacy on-disk report written
127    /// before the typed origin existed. Gating the `via` fallback on a genuinely
128    /// ABSENT origin (not on [`from_report`](Self::from_report) returning `None`)
129    /// is what makes the typed field strictly stronger: a report that DOES carry
130    /// an `origin` field — parsed or malformed/hand-edited — never earns merge
131    /// status on a forged `via` string alone. This mirrors
132    /// `supervise::outcome::classify`'s merge gate exactly, so the reducer, the
133    /// `landed` fallback, and `run wait`'s `merged` flag all agree on the one
134    /// merge truth.
135    ///
136    /// Requires `success == true` and `cancelled` absent/`false`: a payload
137    /// carrying the merge marker but `success: false` (malformed/spoofed) or a
138    /// cancel is NOT a merge. Boolean typing is strict — a non-boolean `success`
139    /// / `cancelled` reads as not-a-merge rather than erroring, so a replay of
140    /// such a dead event stays a clean no-op.
141    #[must_use]
142    pub fn report_is_confirmed_merge(report: &Value) -> bool {
143        let success = matches!(report.get("success"), Some(Value::Bool(true)));
144        let not_cancelled = matches!(
145            report.get("cancelled"),
146            None | Some(Value::Null | Value::Bool(false))
147        );
148        if !(success && not_cancelled) {
149            return false;
150        }
151        // Prefer the typed origin; the legacy `via` string is authority ONLY when
152        // no `origin` field is present (a pre-typed-origin on-disk report).
153        let is_run_merge_origin = matches!(
154            Self::from_report(report),
155            Some(ReportOrigin::RunMerge { .. })
156        );
157        let origin_present = report.get(REPORT_ORIGIN_KEY).is_some();
158        let legacy_via_merge = !origin_present
159            && report.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
160        is_run_merge_origin || legacy_via_merge
161    }
162
163    /// Stamp this origin into a report payload under [`REPORT_ORIGIN_KEY`],
164    /// overwriting any existing value. A no-op if `report` is not a JSON object
165    /// (callers always pass an object — the §7.3 validator rejects non-objects
166    /// before this point).
167    pub fn stamp(&self, report: &mut Value) {
168        if let Some(obj) = report.as_object_mut() {
169            // Serializing a tagged enum with only `Option::None` extra fields
170            // yields a plain object, so this never fails for these variants.
171            if let Ok(v) = serde_json::to_value(self) {
172                obj.insert(REPORT_ORIGIN_KEY.to_string(), v);
173            }
174        }
175    }
176}
177
178/// A §7.3 report payload failed structural validation.
179///
180/// Every variant describes one schema violation. The CLI renders these as
181/// a `schema_violation` error; [`ReportValidationError::expected`] supplies
182/// the machine-readable `expected` hint for the variants that carry one.
183#[derive(Debug, thiserror::Error)]
184pub enum ReportValidationError {
185    /// The payload root was not a JSON object.
186    #[error("report payload must be a JSON object")]
187    NotObject,
188
189    /// The required `success` field was absent.
190    #[error("report payload missing required field `success`")]
191    MissingSuccess,
192
193    /// `success` was present but not a boolean.
194    #[error("field `success` must be a boolean")]
195    SuccessNotBoolean,
196
197    /// `summary` was present but not a string (or null).
198    #[error("field `summary` must be a string")]
199    SummaryNotString,
200
201    /// `cancelled` was present but not a boolean.
202    #[error("field `cancelled` must be a boolean")]
203    CancelledNotBoolean,
204
205    /// `reason` was present but not a string.
206    #[error("field `reason` must be a string")]
207    ReasonNotString,
208
209    /// `cancelled: true` was paired with `success: true` (§7.7 forbids it).
210    #[error("`cancelled: true` requires `success: false`")]
211    CancelledRequiresSuccessFalse,
212
213    /// `cancelled: true` lacked a non-empty `reason` string (§7.7).
214    #[error("`cancelled: true` requires a non-empty `reason` string")]
215    CancelledRequiresReason,
216
217    /// `discussion_items` was present but not an array.
218    #[error("field `discussion_items` must be an array")]
219    DiscussionItemsNotArray,
220
221    /// A `discussion_items` element was not a JSON object.
222    #[error("discussion_items[{index}] must be a JSON object")]
223    DiscussionItemNotObject {
224        /// Index of the offending element.
225        index: usize,
226    },
227
228    /// A `discussion_items` element lacked a non-empty `topic` string.
229    #[error("discussion_items[{index}].topic must be a non-empty string")]
230    DiscussionItemTopicMissing {
231        /// Index of the offending element.
232        index: usize,
233    },
234
235    /// A `discussion_items` element's `severity` was not a string.
236    #[error("discussion_items[{index}].severity must be a string")]
237    DiscussionItemSeverityNotString {
238        /// Index of the offending element.
239        index: usize,
240    },
241
242    /// `spinoff_proposals` was present but not an array.
243    #[error("field `spinoff_proposals` must be an array")]
244    SpinoffProposalsNotArray,
245
246    /// A `spinoff_proposals` element was not a JSON object.
247    #[error("spinoff_proposals[{index}] must be a JSON object")]
248    SpinoffProposalNotObject {
249        /// Index of the offending element.
250        index: usize,
251    },
252
253    /// A `spinoff_proposals` element lacked a non-empty `proposed_title`.
254    #[error("spinoff_proposals[{index}].proposed_title must be a non-empty string")]
255    SpinoffProposalTitleMissing {
256        /// Index of the offending element.
257        index: usize,
258    },
259
260    /// A `spinoff_proposals` element's `proposed_kind` was not a string.
261    #[error("spinoff_proposals[{index}].proposed_kind must be a string")]
262    SpinoffProposalKindNotString {
263        /// Index of the offending element.
264        index: usize,
265    },
266
267    /// A `spinoff_proposals` element's `proposed_kind` was not a known [`Kind`].
268    #[error("spinoff_proposals[{index}].proposed_kind `{kind}` is not a known kind")]
269    SpinoffProposalKindUnknown {
270        /// Index of the offending element.
271        index: usize,
272        /// The rejected kind string.
273        kind: String,
274    },
275
276    /// A `spinoff_proposals` element's `rationale` was not a string (or null).
277    #[error("spinoff_proposals[{index}].rationale must be a string")]
278    SpinoffProposalRationaleNotString {
279        /// Index of the offending element.
280        index: usize,
281    },
282
283    /// A declared string-array field was not an array.
284    #[error("field `{field}` must be an array")]
285    FieldNotArray {
286        /// The offending field name.
287        field: String,
288    },
289
290    /// An element of a declared string-array field was not a string.
291    #[error("{field}[{index}] must be a string")]
292    FieldElementNotString {
293        /// The offending field name.
294        field: String,
295        /// Index of the offending element.
296        index: usize,
297    },
298
299    /// A nested path expected to hold a string array was not an array.
300    #[error("{path} must be an array")]
301    PathNotArray {
302        /// Dotted/indexed path to the offending value.
303        path: String,
304    },
305
306    /// An element at a nested string-array path was not a string.
307    #[error("{path}[{index}] must be a string")]
308    PathElementNotString {
309        /// Dotted/indexed path to the offending array.
310        path: String,
311        /// Index of the offending element.
312        index: usize,
313    },
314}
315
316impl ReportValidationError {
317    /// The machine-readable `expected` hint for this error, if any.
318    ///
319    /// Mirrors the `with_expected(...)` payloads the CLI previously
320    /// attached inline, so callers can surface the same structured hint.
321    #[must_use]
322    pub fn expected(&self) -> Option<Value> {
323        match self {
324            Self::MissingSuccess | Self::SuccessNotBoolean => {
325                Some(serde_json::json!({"field": "success", "type": "boolean"}))
326            }
327            // Source the accepted kinds from the enum so the hint can never
328            // drift from what the validator actually accepts (see
329            // `Kind::WIRE_NAMES` and its serde round-trip test).
330            Self::SpinoffProposalKindUnknown { .. } => Some(serde_json::json!(Kind::WIRE_NAMES)),
331            _ => None,
332        }
333    }
334}
335
336/// Validate a §7.3 report payload's structural shape.
337///
338/// Rejects anything obviously not a report before the reducer ever sees
339/// it, so the caller can name the offending field instead of bubbling a
340/// generic `CorruptEventLog`. Keeps the current validation logic verbatim;
341/// this is a relocation, not a tightening.
342///
343/// # Errors
344///
345/// Returns a [`ReportValidationError`] describing the first schema
346/// violation found.
347pub fn validate_report_payload(data: &Value) -> Result<(), ReportValidationError> {
348    let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
349
350    validate_required_fields(obj)?;
351
352    if let Some(v) = obj.get("summary") {
353        if !v.is_string() && !v.is_null() {
354            return Err(ReportValidationError::SummaryNotString);
355        }
356    }
357
358    validate_discussion_items(obj.get("discussion_items"))?;
359    validate_spinoff_proposals(obj.get("spinoff_proposals"))?;
360    validate_string_array(
361        obj.get("wrap_up_recommendations"),
362        "wrap_up_recommendations",
363    )?;
364    Ok(())
365}
366
367/// Validate the REQUIRED, correctness-bearing fields — `success` and the
368/// `cancelled`/`reason` §7.7 cross-constraints. These are strict in BOTH
369/// validation modes: they gate the terminal outcome and teardown, so a malformed
370/// one is never degraded to a warning (issue `merge-report-schema-lenience`).
371fn validate_required_fields(
372    obj: &serde_json::Map<String, Value>,
373) -> Result<(), ReportValidationError> {
374    // `success` is the one strictly required field per §7.3. A cancel-
375    // synthesized report (§7.7) may carry `cancelled: true` AND
376    // `success: false` — both are still booleans on the wire.
377    let success = obj
378        .get("success")
379        .ok_or(ReportValidationError::MissingSuccess)?;
380    if !success.is_boolean() {
381        return Err(ReportValidationError::SuccessNotBoolean);
382    }
383
384    let cancelled = match obj.get("cancelled") {
385        None | Some(Value::Null) => false,
386        Some(v) => v
387            .as_bool()
388            .ok_or(ReportValidationError::CancelledNotBoolean)?,
389    };
390    let reason = match obj.get("reason") {
391        None | Some(Value::Null) => None,
392        Some(v) => Some(v.as_str().ok_or(ReportValidationError::ReasonNotString)?),
393    };
394
395    // §7.7: a cancel-synthesized report carries `cancelled: true,
396    // success: false, reason: <non-empty>`. Allowing `success: true`
397    // alongside `cancelled: true` would persist a contradiction (the
398    // reducer prioritizes `cancelled`, so the node would be cancelled
399    // while `last_report.success == true`).
400    if cancelled {
401        // `success` was confirmed a boolean above, so this never panics;
402        // `expect` documents that invariant rather than masking a reorder
403        // bug behind `unwrap_or(false)`.
404        if success
405            .as_bool()
406            .expect("success validated as boolean above")
407        {
408            return Err(ReportValidationError::CancelledRequiresSuccessFalse);
409        }
410        match reason {
411            Some(s) if !s.trim().is_empty() => {}
412            _ => return Err(ReportValidationError::CancelledRequiresReason),
413        }
414    }
415    Ok(())
416}
417
418fn validate_discussion_items(v: Option<&Value>) -> Result<(), ReportValidationError> {
419    let arr = match v {
420        Some(Value::Array(a)) => a,
421        Some(_) => return Err(ReportValidationError::DiscussionItemsNotArray),
422        None => return Ok(()),
423    };
424    for (i, item) in arr.iter().enumerate() {
425        validate_discussion_item(item, i)?;
426    }
427    Ok(())
428}
429
430/// Validate ONE `discussion_items` element. Extracted so both the strict
431/// validator (first error wins) and the lenient sanitizer (drop the offending
432/// element, keep the rest) share the exact same per-element rules.
433fn validate_discussion_item(item: &Value, index: usize) -> Result<(), ReportValidationError> {
434    let obj = item
435        .as_object()
436        .ok_or(ReportValidationError::DiscussionItemNotObject { index })?;
437    let topic = obj.get("topic").and_then(Value::as_str);
438    if topic.is_none_or(|t| t.trim().is_empty()) {
439        return Err(ReportValidationError::DiscussionItemTopicMissing { index });
440    }
441    if let Some(sev) = obj.get("severity") {
442        if !sev.is_string() {
443            return Err(ReportValidationError::DiscussionItemSeverityNotString { index });
444        }
445        // §7.3 example lists "discuss|critical" but the design
446        // calls for forward-compatibility — accept any string and
447        // let the supervisor interpret unknown severities. (A
448        // CLI-side closed-set check would deadlock agents shipped
449        // ahead of a CLI release; see review #2/DeepSeek and #15
450        // /Claude.)
451    }
452    if let Some(opts) = obj.get("options") {
453        validate_string_array_at(opts, &format!("discussion_items[{index}].options"))?;
454    }
455    Ok(())
456}
457
458fn validate_spinoff_proposals(v: Option<&Value>) -> Result<(), ReportValidationError> {
459    let arr = match v {
460        Some(Value::Array(a)) => a,
461        Some(_) => return Err(ReportValidationError::SpinoffProposalsNotArray),
462        None => return Ok(()),
463    };
464    for (i, item) in arr.iter().enumerate() {
465        validate_spinoff_proposal(item, i)?;
466    }
467    Ok(())
468}
469
470/// Validate ONE `spinoff_proposals` element. Extracted so both the strict
471/// validator and the lenient sanitizer share the exact same per-element rules —
472/// including the `proposed_title`/`proposed_kind` field names whose intuitive
473/// typos (`title`/`detail`) motivated the lenient mode (issue
474/// `merge-report-schema-lenience`).
475fn validate_spinoff_proposal(item: &Value, index: usize) -> Result<(), ReportValidationError> {
476    let obj = item
477        .as_object()
478        .ok_or(ReportValidationError::SpinoffProposalNotObject { index })?;
479    let title = obj.get("proposed_title").and_then(Value::as_str);
480    if title.is_none_or(|t| t.trim().is_empty()) {
481        return Err(ReportValidationError::SpinoffProposalTitleMissing { index });
482    }
483    let kind_str = obj
484        .get("proposed_kind")
485        .and_then(Value::as_str)
486        .ok_or(ReportValidationError::SpinoffProposalKindNotString { index })?;
487    // Reject unknown kinds at the boundary so the supervisor never
488    // has to translate a generic `CorruptEventLog` for the user. The
489    // accepted set is the enum's *creatable* wire names — the read-only
490    // `Kind::Unknown` catch-all is deliberately excluded (a proposal must
491    // name a live kind), so this checks membership rather than round-tripping
492    // through serde (which would silently map any unknown string to
493    // `Kind::Unknown`).
494    if !Kind::WIRE_NAMES.contains(&kind_str) {
495        return Err(ReportValidationError::SpinoffProposalKindUnknown {
496            index,
497            kind: kind_str.to_string(),
498        });
499    }
500    if let Some(rationale) = obj.get("rationale") {
501        if !rationale.is_string() && !rationale.is_null() {
502            return Err(ReportValidationError::SpinoffProposalRationaleNotString { index });
503        }
504    }
505    Ok(())
506}
507
508/// Path-aware string-array validator. Used for nested fields where the
509/// caller wants to embed an index in the error message.
510fn validate_string_array_at(v: &Value, path: &str) -> Result<(), ReportValidationError> {
511    let arr = v
512        .as_array()
513        .ok_or_else(|| ReportValidationError::PathNotArray {
514            path: path.to_string(),
515        })?;
516    for (i, item) in arr.iter().enumerate() {
517        if !item.is_string() {
518            return Err(ReportValidationError::PathElementNotString {
519                path: path.to_string(),
520                index: i,
521            });
522        }
523    }
524    Ok(())
525}
526
527fn validate_string_array(v: Option<&Value>, field: &str) -> Result<(), ReportValidationError> {
528    let arr = match v {
529        Some(Value::Array(a)) => a,
530        Some(_) => {
531            return Err(ReportValidationError::FieldNotArray {
532                field: field.to_string(),
533            })
534        }
535        None => return Ok(()),
536    };
537    for (i, item) in arr.iter().enumerate() {
538        if !item.is_string() {
539            return Err(ReportValidationError::FieldElementNotString {
540                field: field.to_string(),
541                index: i,
542            });
543        }
544    }
545    Ok(())
546}
547
548/// A machine-readable warning that one advisory report field (or one element of
549/// an advisory array) was dropped during lenient sanitization
550/// (issue `merge-report-schema-lenience`).
551///
552/// Emitted by [`sanitize_report_advisory`] and surfaced in `run merge`'s JSON
553/// envelope so an agent reads a structured record of what was discarded instead
554/// of regex-parsing prose. The dropped data was never correctness-bearing (see
555/// [`sanitize_report_advisory`] for the strict/advisory split), so a warning —
556/// not a rejected merge — is the right severity.
557#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
558pub struct AdvisoryWarning {
559    /// The advisory field the drop applies to, e.g. `spinoff_proposals`.
560    pub field: String,
561    /// The element index dropped, when the field itself was kept but one element
562    /// was invalid. Absent (`None`) when the entire field was dropped (e.g. it was
563    /// present but not an array).
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub index: Option<usize>,
566    /// Human-readable reason — the underlying [`ReportValidationError`] rendered.
567    pub reason: String,
568}
569
570impl AdvisoryWarning {
571    /// Render as a single human-readable line for the CLI's `warnings` string list,
572    /// e.g. `dropped spinoff_proposals[0]: … must be a non-empty string`.
573    #[must_use]
574    pub fn to_message(&self) -> String {
575        match self.index {
576            Some(i) => format!("dropped {}[{i}]: {}", self.field, self.reason),
577            None => format!("dropped advisory field `{}`: {}", self.field, self.reason),
578        }
579    }
580}
581
582/// The result of [`sanitize_report_advisory`]: a report with malformed *advisory*
583/// sections removed, plus the machine-readable [`AdvisoryWarning`]s describing
584/// every drop.
585#[derive(Debug, Clone)]
586pub struct SanitizedReport {
587    /// The report with malformed advisory fields/elements dropped. Required and
588    /// correctness-bearing fields are untouched (they were validated strictly).
589    pub report: Value,
590    /// One entry per dropped advisory field or element; empty when nothing was
591    /// dropped (the report validated cleanly).
592    pub warnings: Vec<AdvisoryWarning>,
593}
594
595/// Validate a §7.3 report payload with **lenient advisory handling** — the
596/// merge-first posture of `run merge --report-file` (issue
597/// `merge-report-schema-lenience`).
598///
599/// The REQUIRED, correctness-bearing fields are still strict — a violation here
600/// returns `Err` exactly as [`validate_report_payload`] would:
601/// - the payload root must be a JSON object,
602/// - `success` must be present and boolean, and
603/// - the `cancelled`/`reason` §7.7 cross-constraints must hold.
604///
605/// These gate the node's terminal outcome and the supervisor's teardown, so a
606/// malformed one is never silently degraded — that would risk mis-terminalizing a
607/// node or stranding teardown.
608///
609/// Everything else is **advisory** and degrades gracefully: a malformed value is
610/// dropped from the returned [`SanitizedReport::report`] and recorded as an
611/// [`AdvisoryWarning`] instead of failing the call. This covers:
612/// - `summary` (a non-string/non-null scalar → field dropped),
613/// - `discussion_items` / `spinoff_proposals` (a non-array → whole field dropped;
614///   an invalid element → that WHOLE element dropped, valid siblings kept),
615/// - `wrap_up_recommendations` (a non-array → field dropped; a non-string element
616///   → that element dropped).
617///
618/// **Element granularity is coarse by design:** an element is validated as a unit,
619/// so a malformed *nested* value (e.g. a non-string inside a `discussion_items[i].
620/// options` array, or a bad `spinoff_proposals[i].proposed_kind`) drops the entire
621/// containing element — its valid siblings like `topic` go with it. This keeps the
622/// lenient rules byte-identical to the strict per-element validators (no rule
623/// drift) at the cost of not salvaging partial elements; salvaging a typo is what
624/// motivated leniency, and dropping one malformed proposal is an acceptable price.
625///
626/// This is what stops an advisory-field typo (the recurring `title`/`detail`
627/// instead of `proposed_title`/`proposed_kind`/`rationale`) from rejecting the
628/// whole terminal report and blocking a clean, already-committed code merge.
629///
630/// **Provenance is NOT validated here — the caller owns it.** This function
631/// touches only the required and advisory fields above; every OTHER top-level key
632/// (`origin`, `via`, and any unknown agent key) is preserved verbatim from the
633/// input. It deliberately does NOT establish provenance trust: a caller that
634/// persists the result MUST stamp the authoritative [`ReportOrigin`] itself (as
635/// `run merge` does after this returns) — never trust a payload-supplied `origin`
636/// / `via`. See [`ReportOrigin`]'s "never accepted from an untrusted payload"
637/// contract; this sanitizer is a shape check, not a trust boundary.
638///
639/// # Errors
640///
641/// Returns a [`ReportValidationError`] only for a violation of a required field
642/// (root shape, `success`, `cancelled`/`reason`). Advisory violations never error.
643pub fn sanitize_report_advisory(data: &Value) -> Result<SanitizedReport, ReportValidationError> {
644    let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
645
646    // Required/correctness-bearing fields stay strict.
647    validate_required_fields(obj)?;
648
649    let mut out = obj.clone();
650    let mut warnings = Vec::new();
651
652    // `summary` — advisory descriptive scalar. Drop a malformed one.
653    if let Some(v) = obj.get("summary") {
654        if !v.is_string() && !v.is_null() {
655            out.remove("summary");
656            warnings.push(AdvisoryWarning {
657                field: "summary".to_string(),
658                index: None,
659                reason: ReportValidationError::SummaryNotString.to_string(),
660            });
661        }
662    }
663
664    sanitize_element_array(
665        &mut out,
666        "discussion_items",
667        ReportValidationError::DiscussionItemsNotArray,
668        validate_discussion_item,
669        &mut warnings,
670    );
671    sanitize_element_array(
672        &mut out,
673        "spinoff_proposals",
674        ReportValidationError::SpinoffProposalsNotArray,
675        validate_spinoff_proposal,
676        &mut warnings,
677    );
678    sanitize_string_array_field(&mut out, "wrap_up_recommendations", &mut warnings);
679
680    Ok(SanitizedReport {
681        report: Value::Object(out),
682        warnings,
683    })
684}
685
686/// Leniently sanitize one advisory array-of-objects field in place: a
687/// present-but-non-array field is dropped whole; each element that fails
688/// `validate` is dropped, valid siblings retained. Every drop appends an
689/// [`AdvisoryWarning`]. Indices in the warnings are the ORIGINAL element
690/// positions, so they line up with what the agent wrote.
691fn sanitize_element_array(
692    obj: &mut serde_json::Map<String, Value>,
693    field: &str,
694    not_array_err: ReportValidationError,
695    validate: fn(&Value, usize) -> Result<(), ReportValidationError>,
696    warnings: &mut Vec<AdvisoryWarning>,
697) {
698    let Some(v) = obj.get(field) else { return };
699    let Some(arr) = v.as_array() else {
700        obj.remove(field);
701        warnings.push(AdvisoryWarning {
702            field: field.to_string(),
703            index: None,
704            reason: not_array_err.to_string(),
705        });
706        return;
707    };
708    let mut kept = Vec::with_capacity(arr.len());
709    for (i, item) in arr.iter().enumerate() {
710        match validate(item, i) {
711            Ok(()) => kept.push(item.clone()),
712            Err(e) => warnings.push(AdvisoryWarning {
713                field: field.to_string(),
714                index: Some(i),
715                reason: e.to_string(),
716            }),
717        }
718    }
719    obj.insert(field.to_string(), Value::Array(kept));
720}
721
722/// Leniently sanitize one advisory string-array field in place: a
723/// present-but-non-array field is dropped whole; each non-string element is
724/// dropped, string siblings retained.
725fn sanitize_string_array_field(
726    obj: &mut serde_json::Map<String, Value>,
727    field: &str,
728    warnings: &mut Vec<AdvisoryWarning>,
729) {
730    let Some(v) = obj.get(field) else { return };
731    let Some(arr) = v.as_array() else {
732        obj.remove(field);
733        warnings.push(AdvisoryWarning {
734            field: field.to_string(),
735            index: None,
736            reason: ReportValidationError::FieldNotArray {
737                field: field.to_string(),
738            }
739            .to_string(),
740        });
741        return;
742    };
743    let mut kept = Vec::with_capacity(arr.len());
744    for (i, item) in arr.iter().enumerate() {
745        if item.is_string() {
746            kept.push(item.clone());
747        } else {
748            warnings.push(AdvisoryWarning {
749                field: field.to_string(),
750                index: Some(i),
751                reason: ReportValidationError::FieldElementNotString {
752                    field: field.to_string(),
753                    index: i,
754                }
755                .to_string(),
756            });
757        }
758    }
759    obj.insert(field.to_string(), Value::Array(kept));
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use serde_json::json;
766
767    // --- valid payloads ---
768
769    #[test]
770    fn validates_minimal_success_payload() {
771        let v = json!({"success": true});
772        assert!(validate_report_payload(&v).is_ok());
773    }
774
775    #[test]
776    fn validates_full_success_payload() {
777        let v = json!({
778            "success": true,
779            "summary": "did the thing",
780            "discussion_items": [
781                {"topic": "naming", "severity": "discuss", "options": ["a", "b"]},
782            ],
783            "spinoff_proposals": [
784                {"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
785            ],
786            "wrap_up_recommendations": ["rebase", "squash"],
787        });
788        assert!(validate_report_payload(&v).is_ok());
789    }
790
791    #[test]
792    fn discussion_item_unknown_severity_accepted_for_forward_compat() {
793        // Forward-compat: a supervisor may add new severities without a
794        // CLI release. The validator only enforces severity is a string.
795        let v = json!({
796            "success": true,
797            "discussion_items": [{"topic": "x", "severity": "info"}],
798        });
799        assert!(validate_report_payload(&v).is_ok());
800    }
801
802    #[test]
803    fn cancel_synthesized_report_shape_ok() {
804        // Mirror of run cancel's synthesized payload (run/cancel.rs).
805        let v = json!({
806            "success": false,
807            "cancelled": true,
808            "reason": "cancelled by user",
809            "summary": "Run cancelled before agent reported.",
810            "discussion_items": [],
811            "spinoff_proposals": [],
812            "wrap_up_recommendations": [],
813        });
814        assert!(validate_report_payload(&v).is_ok());
815    }
816
817    // --- invalid payloads ---
818
819    #[test]
820    fn non_object_root_rejected() {
821        let v = json!([1, 2, 3]);
822        assert!(matches!(
823            validate_report_payload(&v),
824            Err(ReportValidationError::NotObject)
825        ));
826    }
827
828    #[test]
829    fn missing_success_rejected() {
830        let v = json!({"summary": "no success field"});
831        let err = validate_report_payload(&v).unwrap_err();
832        assert!(matches!(err, ReportValidationError::MissingSuccess));
833        // Missing `success` carries the structured `expected` hint.
834        assert_eq!(
835            err.expected(),
836            Some(json!({"field": "success", "type": "boolean"}))
837        );
838    }
839
840    #[test]
841    fn success_variants_carry_field_type_hint() {
842        // Both `success` errors reproduce the exact CLI hint, byte-for-byte.
843        let hint = Some(json!({"field": "success", "type": "boolean"}));
844        assert_eq!(ReportValidationError::MissingSuccess.expected(), hint);
845        assert_eq!(ReportValidationError::SuccessNotBoolean.expected(), hint);
846    }
847
848    #[test]
849    fn summary_must_be_string() {
850        let v = json!({"success": true, "summary": 42});
851        assert!(matches!(
852            validate_report_payload(&v),
853            Err(ReportValidationError::SummaryNotString)
854        ));
855    }
856
857    #[test]
858    fn discussion_item_options_non_array_rejected() {
859        let v = json!({
860            "success": true,
861            "discussion_items": [{"topic": "x", "options": "not-an-array"}],
862        });
863        assert!(matches!(
864            validate_report_payload(&v),
865            Err(ReportValidationError::PathNotArray { .. })
866        ));
867    }
868
869    #[test]
870    fn cancelled_requires_non_whitespace_reason() {
871        let v = json!({"success": false, "cancelled": true, "reason": "   "});
872        assert!(matches!(
873            validate_report_payload(&v),
874            Err(ReportValidationError::CancelledRequiresReason)
875        ));
876    }
877
878    #[test]
879    fn non_boolean_success_rejected() {
880        let v = json!({"success": "yes"});
881        assert!(matches!(
882            validate_report_payload(&v),
883            Err(ReportValidationError::SuccessNotBoolean)
884        ));
885    }
886
887    #[test]
888    fn discussion_item_missing_topic_rejected() {
889        let v = json!({
890            "success": true,
891            "discussion_items": [{"severity": "discuss"}],
892        });
893        assert!(matches!(
894            validate_report_payload(&v),
895            Err(ReportValidationError::DiscussionItemTopicMissing { index: 0 })
896        ));
897    }
898
899    #[test]
900    fn discussion_item_non_string_severity_rejected() {
901        let v = json!({
902            "success": true,
903            "discussion_items": [{"topic": "x", "severity": 42}],
904        });
905        assert!(matches!(
906            validate_report_payload(&v),
907            Err(ReportValidationError::DiscussionItemSeverityNotString { index: 0 })
908        ));
909    }
910
911    #[test]
912    fn discussion_item_options_must_be_strings() {
913        let v = json!({
914            "success": true,
915            "discussion_items": [{"topic": "x", "options": [1, 2]}],
916        });
917        assert!(matches!(
918            validate_report_payload(&v),
919            Err(ReportValidationError::PathElementNotString { index: 0, .. })
920        ));
921    }
922
923    #[test]
924    fn spinoff_unknown_proposed_kind_rejected() {
925        let v = json!({
926            "success": true,
927            "spinoff_proposals": [{"proposed_title": "x", "proposed_kind": "not-a-kind"}],
928        });
929        let err = validate_report_payload(&v).unwrap_err();
930        assert!(matches!(
931            err,
932            ReportValidationError::SpinoffProposalKindUnknown { index: 0, .. }
933        ));
934        // Unknown kind surfaces the exact closed-set of known kinds, and
935        // that set is the enum's own wire names (no drift).
936        assert_eq!(err.expected(), Some(json!(crate::schema::Kind::WIRE_NAMES)));
937        assert_eq!(
938            err.expected(),
939            Some(json!([
940                "spinoff",
941                "research",
942                "technical-decision",
943                "fan-out"
944            ]))
945        );
946    }
947
948    #[test]
949    fn spinoff_missing_kind_rejected() {
950        let v = json!({
951            "success": true,
952            "spinoff_proposals": [{"proposed_title": "x"}],
953        });
954        assert!(matches!(
955            validate_report_payload(&v),
956            Err(ReportValidationError::SpinoffProposalKindNotString { index: 0 })
957        ));
958    }
959
960    #[test]
961    fn cancelled_requires_success_false() {
962        let v = json!({"success": true, "cancelled": true, "reason": "x"});
963        assert!(matches!(
964            validate_report_payload(&v),
965            Err(ReportValidationError::CancelledRequiresSuccessFalse)
966        ));
967    }
968
969    #[test]
970    fn cancelled_requires_reason() {
971        let v = json!({"success": false, "cancelled": true});
972        assert!(matches!(
973            validate_report_payload(&v),
974            Err(ReportValidationError::CancelledRequiresReason)
975        ));
976    }
977
978    // --- ReportOrigin (issue `typed-report-origin`) ---
979
980    #[test]
981    fn report_origin_round_trips_through_a_report() {
982        let cases = [
983            ReportOrigin::Agent,
984            ReportOrigin::Supervisor,
985            ReportOrigin::RunMerge {
986                op_id: Some("op-123".into()),
987                worker_oid: Some("deadbeef".into()),
988            },
989            ReportOrigin::RunMerge {
990                op_id: None,
991                worker_oid: None,
992            },
993        ];
994        for origin in cases {
995            let mut report = json!({ "success": true });
996            origin.stamp(&mut report);
997            assert_eq!(
998                ReportOrigin::from_report(&report),
999                Some(origin.clone()),
1000                "round-trip: {origin:?}"
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn report_origin_serializes_with_kind_tag() {
1007        let mut report = json!({ "success": true });
1008        ReportOrigin::Agent.stamp(&mut report);
1009        assert_eq!(report["origin"], json!({ "kind": "agent" }));
1010
1011        let mut merge = json!({ "success": true });
1012        ReportOrigin::RunMerge {
1013            op_id: Some("op-9".into()),
1014            worker_oid: Some("abc123".into()),
1015        }
1016        .stamp(&mut merge);
1017        assert_eq!(
1018            merge["origin"],
1019            json!({ "kind": "run-merge", "op_id": "op-9", "worker_oid": "abc123" })
1020        );
1021
1022        // A bare run-merge (legacy unguarded path) omits the null OID fields.
1023        let mut bare = json!({ "success": true });
1024        ReportOrigin::RunMerge {
1025            op_id: None,
1026            worker_oid: None,
1027        }
1028        .stamp(&mut bare);
1029        assert_eq!(bare["origin"], json!({ "kind": "run-merge" }));
1030    }
1031
1032    #[test]
1033    fn report_origin_absent_or_malformed_is_none() {
1034        // A legacy report with no origin field.
1035        assert_eq!(ReportOrigin::from_report(&json!({ "success": true })), None);
1036        // A malformed origin is treated as absent (conservative fallback), never
1037        // an error that could brick classification.
1038        assert_eq!(
1039            ReportOrigin::from_report(&json!({ "origin": "not-an-object" })),
1040            None
1041        );
1042        assert_eq!(
1043            ReportOrigin::from_report(&json!({ "origin": { "kind": "bogus" } })),
1044            None
1045        );
1046    }
1047
1048    #[test]
1049    fn report_origin_stamp_overwrites_a_supplied_value() {
1050        // The enforcement `node report` relies on: stamping Agent discards any
1051        // caller-supplied merge/supervisor origin.
1052        let mut report = json!({
1053            "success": true,
1054            "origin": { "kind": "run-merge", "op_id": "spoofed" }
1055        });
1056        ReportOrigin::Agent.stamp(&mut report);
1057        assert_eq!(
1058            ReportOrigin::from_report(&report),
1059            Some(ReportOrigin::Agent)
1060        );
1061    }
1062
1063    #[test]
1064    fn report_is_confirmed_merge_prefers_typed_origin() {
1065        // A RunMerge origin authorizes a merge even with NO legacy `via` string.
1066        let mut merged = json!({ "success": true });
1067        ReportOrigin::RunMerge {
1068            op_id: Some("op-1".into()),
1069            worker_oid: Some("abc".into()),
1070        }
1071        .stamp(&mut merged);
1072        assert!(ReportOrigin::report_is_confirmed_merge(&merged));
1073
1074        // A bare RunMerge origin (legacy unguarded path, no OIDs) still counts.
1075        let mut bare = json!({ "success": true });
1076        ReportOrigin::RunMerge {
1077            op_id: None,
1078            worker_oid: None,
1079        }
1080        .stamp(&mut bare);
1081        assert!(ReportOrigin::report_is_confirmed_merge(&bare));
1082    }
1083
1084    #[test]
1085    fn report_is_confirmed_merge_legacy_via_only_when_origin_absent() {
1086        // Legacy report (no origin field): the `via` marker is honored.
1087        assert!(ReportOrigin::report_is_confirmed_merge(&json!({
1088            "success": true, "via": "explicit-merge"
1089        })));
1090
1091        // Present-but-Agent origin + a forged `via`: NOT a merge. Merge authority
1092        // is the run-merge path; an agent report can't fabricate one on `via`.
1093        let mut agent = json!({ "success": true, "via": "explicit-merge" });
1094        ReportOrigin::Agent.stamp(&mut agent);
1095        assert!(
1096            !ReportOrigin::report_is_confirmed_merge(&agent),
1097            "an Agent-origin report must not be a merge even with a forged via"
1098        );
1099
1100        // Present-but-MALFORMED origin + a forged `via`: NOT a merge — a corrupt
1101        // origin field must not re-unlock the legacy via path.
1102        assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1103            "success": true, "via": "explicit-merge", "origin": "garbage-not-an-object"
1104        })));
1105        assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1106            "success": true, "via": "explicit-merge", "origin": { "kind": "bogus" }
1107        })));
1108    }
1109
1110    #[test]
1111    fn report_is_confirmed_merge_requires_success_and_not_cancelled() {
1112        // success:false with a merge marker is not a merge (malformed/spoofed).
1113        assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1114            "success": false, "via": "explicit-merge"
1115        })));
1116        // A RunMerge origin on a success:false report is likewise not a merge.
1117        let mut neg = json!({ "success": false });
1118        ReportOrigin::RunMerge {
1119            op_id: None,
1120            worker_oid: None,
1121        }
1122        .stamp(&mut neg);
1123        assert!(!ReportOrigin::report_is_confirmed_merge(&neg));
1124        // A cancelled report never counts, even with a RunMerge origin riding along.
1125        let mut cancelled = json!({ "success": false, "cancelled": true, "reason": "x" });
1126        ReportOrigin::RunMerge {
1127            op_id: None,
1128            worker_oid: None,
1129        }
1130        .stamp(&mut cancelled);
1131        assert!(!ReportOrigin::report_is_confirmed_merge(&cancelled));
1132        // Non-boolean success (strict typing) is not a merge.
1133        assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1134            "success": "true", "via": "explicit-merge"
1135        })));
1136    }
1137
1138    #[test]
1139    fn report_origin_stamp_on_non_object_is_noop() {
1140        let mut not_obj = json!([1, 2, 3]);
1141        ReportOrigin::Agent.stamp(&mut not_obj);
1142        assert_eq!(not_obj, json!([1, 2, 3]));
1143    }
1144
1145    #[test]
1146    fn wrap_up_must_be_string_array() {
1147        let v = json!({
1148            "success": true,
1149            "wrap_up_recommendations": ["ok", 42],
1150        });
1151        assert!(matches!(
1152            validate_report_payload(&v),
1153            Err(ReportValidationError::FieldElementNotString { index: 1, .. })
1154        ));
1155    }
1156
1157    // --- lenient advisory sanitization (issue `merge-report-schema-lenience`) ---
1158
1159    #[test]
1160    fn sanitize_clean_report_has_no_warnings() {
1161        let v = json!({
1162            "success": true,
1163            "summary": "did the thing",
1164            "discussion_items": [{"topic": "naming", "severity": "discuss"}],
1165            "spinoff_proposals": [
1166                {"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
1167            ],
1168            "wrap_up_recommendations": ["rebase"],
1169        });
1170        let out = sanitize_report_advisory(&v).unwrap();
1171        assert!(out.warnings.is_empty());
1172        assert_eq!(out.report, v);
1173    }
1174
1175    #[test]
1176    fn sanitize_drops_typoed_spinoff_proposal_with_warning() {
1177        // The exact glasspad-stint foot-gun: `title`/`detail` instead of the
1178        // schema's `proposed_title`/`proposed_kind`/`rationale`. Strict validation
1179        // would reject the whole report and block the merge; lenient drops the
1180        // proposal and warns.
1181        let v = json!({
1182            "success": true,
1183            "summary": "green, reviewed, committed",
1184            "spinoff_proposals": [{"title": "do X later", "detail": "because Y"}],
1185        });
1186        // Strict path rejects it (the behavior the issue is fixing).
1187        assert!(validate_report_payload(&v).is_err());
1188        // Lenient path merges: no error, proposal dropped, one warning.
1189        let out = sanitize_report_advisory(&v).unwrap();
1190        assert_eq!(out.warnings.len(), 1);
1191        assert_eq!(out.warnings[0].field, "spinoff_proposals");
1192        assert_eq!(out.warnings[0].index, Some(0));
1193        assert_eq!(out.report["spinoff_proposals"], json!([]));
1194        // Required + descriptive fields survive untouched.
1195        assert_eq!(out.report["success"], json!(true));
1196        assert_eq!(out.report["summary"], json!("green, reviewed, committed"));
1197    }
1198
1199    #[test]
1200    fn sanitize_keeps_valid_siblings_drops_only_bad_element() {
1201        let v = json!({
1202            "success": true,
1203            "spinoff_proposals": [
1204                {"proposed_title": "keep me", "proposed_kind": "spinoff"},
1205                {"title": "typo, drop me"},
1206                {"proposed_title": "keep me too", "proposed_kind": "research"},
1207            ],
1208        });
1209        let out = sanitize_report_advisory(&v).unwrap();
1210        assert_eq!(out.warnings.len(), 1);
1211        assert_eq!(out.warnings[0].index, Some(1));
1212        let kept = out.report["spinoff_proposals"].as_array().unwrap();
1213        assert_eq!(kept.len(), 2);
1214        assert_eq!(kept[0]["proposed_title"], json!("keep me"));
1215        assert_eq!(kept[1]["proposed_title"], json!("keep me too"));
1216    }
1217
1218    #[test]
1219    fn sanitize_drops_non_array_advisory_field_whole() {
1220        let v = json!({
1221            "success": true,
1222            "discussion_items": "not-an-array",
1223            "wrap_up_recommendations": {"oops": true},
1224        });
1225        let out = sanitize_report_advisory(&v).unwrap();
1226        assert_eq!(out.warnings.len(), 2);
1227        // Both malformed fields removed entirely.
1228        assert!(out.report.get("discussion_items").is_none());
1229        assert!(out.report.get("wrap_up_recommendations").is_none());
1230        let fields: Vec<&str> = out.warnings.iter().map(|w| w.field.as_str()).collect();
1231        assert!(fields.contains(&"discussion_items"));
1232        assert!(fields.contains(&"wrap_up_recommendations"));
1233        // A whole-field drop carries no element index.
1234        assert!(out.warnings.iter().all(|w| w.index.is_none()));
1235    }
1236
1237    #[test]
1238    fn sanitize_drops_non_string_wrap_up_element() {
1239        let v = json!({
1240            "success": true,
1241            "wrap_up_recommendations": ["rebase", 42, "squash"],
1242        });
1243        let out = sanitize_report_advisory(&v).unwrap();
1244        assert_eq!(out.warnings.len(), 1);
1245        assert_eq!(out.warnings[0].index, Some(1));
1246        assert_eq!(
1247            out.report["wrap_up_recommendations"],
1248            json!(["rebase", "squash"])
1249        );
1250    }
1251
1252    #[test]
1253    fn sanitize_drops_malformed_summary() {
1254        let v = json!({"success": true, "summary": 42});
1255        let out = sanitize_report_advisory(&v).unwrap();
1256        assert_eq!(out.warnings.len(), 1);
1257        assert_eq!(out.warnings[0].field, "summary");
1258        assert!(out.report.get("summary").is_none());
1259    }
1260
1261    #[test]
1262    fn sanitize_still_rejects_missing_required_success() {
1263        // Required-field strictness is preserved: no `success` → error, no merge.
1264        let v = json!({"summary": "no success field"});
1265        assert!(matches!(
1266            sanitize_report_advisory(&v),
1267            Err(ReportValidationError::MissingSuccess)
1268        ));
1269    }
1270
1271    #[test]
1272    fn sanitize_still_rejects_non_boolean_success() {
1273        let v = json!({"success": "yes", "spinoff_proposals": [{"title": "x"}]});
1274        assert!(matches!(
1275            sanitize_report_advisory(&v),
1276            Err(ReportValidationError::SuccessNotBoolean)
1277        ));
1278    }
1279
1280    #[test]
1281    fn sanitize_still_rejects_cancelled_contradiction() {
1282        // The §7.7 cross-constraint is correctness-bearing (it drives terminal
1283        // outcome), so it stays strict even in lenient mode.
1284        let v = json!({"success": true, "cancelled": true, "reason": "x"});
1285        assert!(matches!(
1286            sanitize_report_advisory(&v),
1287            Err(ReportValidationError::CancelledRequiresSuccessFalse)
1288        ));
1289    }
1290
1291    #[test]
1292    fn sanitize_rejects_non_object_root() {
1293        let v = json!([1, 2, 3]);
1294        assert!(matches!(
1295            sanitize_report_advisory(&v),
1296            Err(ReportValidationError::NotObject)
1297        ));
1298    }
1299
1300    #[test]
1301    fn sanitize_preserves_unknown_and_provenance_fields() {
1302        // The sanitizer is a SHAPE check, not a trust boundary: it must pass
1303        // through `origin`, `via`, and unknown agent keys untouched. (`run merge`
1304        // re-stamps the authoritative origin/via afterward — provenance trust is
1305        // the caller's job, per the doc contract.)
1306        let v = json!({
1307            "success": true,
1308            "origin": {"kind": "agent"},
1309            "via": "explicit-merge",
1310            "custom_agent_key": {"nested": [1, 2, 3]},
1311            "spinoff_proposals": [{"title": "typo, drop me"}],
1312        });
1313        let out = sanitize_report_advisory(&v).unwrap();
1314        assert_eq!(out.warnings.len(), 1, "only the bad proposal is dropped");
1315        assert_eq!(out.report["origin"], json!({"kind": "agent"}));
1316        assert_eq!(out.report["via"], json!("explicit-merge"));
1317        assert_eq!(out.report["custom_agent_key"], json!({"nested": [1, 2, 3]}));
1318    }
1319
1320    #[test]
1321    fn sanitize_nested_options_drops_whole_discussion_item() {
1322        // A malformed nested `options` drops the ENTIRE element (coarse by design),
1323        // taking the otherwise-valid `topic` with it. Documented behavior.
1324        let v = json!({
1325            "success": true,
1326            "discussion_items": [
1327                {"topic": "keep", "severity": "discuss"},
1328                {"topic": "drop me", "options": ["ok", 42]},
1329            ],
1330        });
1331        let out = sanitize_report_advisory(&v).unwrap();
1332        assert_eq!(out.warnings.len(), 1);
1333        assert_eq!(out.warnings[0].index, Some(1));
1334        let kept = out.report["discussion_items"].as_array().unwrap();
1335        assert_eq!(kept.len(), 1);
1336        assert_eq!(kept[0]["topic"], json!("keep"));
1337    }
1338
1339    #[test]
1340    fn advisory_warning_message_shapes() {
1341        let elem = AdvisoryWarning {
1342            field: "spinoff_proposals".to_string(),
1343            index: Some(2),
1344            reason: "boom".to_string(),
1345        };
1346        assert_eq!(elem.to_message(), "dropped spinoff_proposals[2]: boom");
1347        let whole = AdvisoryWarning {
1348            field: "discussion_items".to_string(),
1349            index: None,
1350            reason: "not an array".to_string(),
1351        };
1352        assert_eq!(
1353            whole.to_message(),
1354            "dropped advisory field `discussion_items`: not an array"
1355        );
1356    }
1357}