Skip to main content

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