Skip to main content

agent_doc_core/
ipc_corruption.rs

1//! # Module: ipc_corruption
2//!
3//! ## Spec
4//! Forensic detector for the `#ipcfullprompt` corruption shape: a full-document
5//! editor-side IPC mutation (e.g. `PatchWatcher.setText`) that **deletes** or
6//! **duplicates** a previously-committed assistant `### Re:` response block while
7//! the user is typing a live prompt. The binary's IPC snapshot-adoption guards
8//! fail closed against the *snapshot*, but the editor-visible buffer can still be
9//! corrupted; this detector turns each occurrence into a durable forensic signal
10//! (default-on capture) without changing any mutation behavior.
11//!
12//! - [`detect_response_block_corruption`] compares a `prior` document (the
13//!   committed baseline) against a `current` document (the live editor buffer /
14//!   candidate) and returns one finding per prior `### Re:` heading that is now
15//!   absent (deleted) or appears more times than it did in `prior` (duplicated).
16//! - [`summarize_findings`] renders findings as a compact, single-line,
17//!   ops.log-friendly string.
18//!
19//! ## Agentic Contracts
20//! - **Detection only.** This module never mutates a document or changes an
21//!   adoption decision. A finding is a forensic log signal, not a block.
22//! - Only headings that were present in `prior` are considered, so a brand-new
23//!   response added this cycle (present in `current`, absent from `prior`) is
24//!   never flagged — that is expected growth, not corruption.
25//! - Heading identity ignores the working-tree-only ` (HEAD)` annotation and
26//!   leading/trailing whitespace, matching how the rest of the binary treats the
27//!   `(HEAD)` boundary artifact.
28//!
29//! ## Evals
30//! - `detect_flags_deleted_prior_response`
31//! - `detect_flags_duplicated_prior_response`
32//! - `detect_ignores_new_response_block`
33//! - `detect_ignores_head_annotation_difference`
34//! - `detect_clean_document_has_no_findings`
35
36use std::collections::BTreeMap;
37
38/// The kind of `#ipcfullprompt` corruption detected for a response heading.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum IpcCorruptionKind {
41    /// A `### Re:` block present in `prior` is entirely absent from `current`.
42    ResponseDeleted,
43    /// A `### Re:` block appears more times in `current` than in `prior`.
44    ResponseDuplicated,
45    /// A canonical structural component marker (e.g. `<!-- /agent:exchange -->`)
46    /// appears more than once — the full-tail duplication shape where the
47    /// post-exchange scaffold (close marker + Queue/Backlog/Icebox) is copied.
48    ScaffoldDuplicated,
49}
50
51impl IpcCorruptionKind {
52    /// Stable label for ops.log lines.
53    pub fn label(&self) -> &'static str {
54        match self {
55            IpcCorruptionKind::ResponseDeleted => "response_deleted",
56            IpcCorruptionKind::ResponseDuplicated => "response_duplicated",
57            IpcCorruptionKind::ScaffoldDuplicated => "scaffold_duplicated",
58        }
59    }
60}
61
62/// One detected corruption of a previously-committed response block.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct IpcCorruptionFinding {
65    pub kind: IpcCorruptionKind,
66    /// The normalized `### Re:` heading text (no `(HEAD)` suffix, trimmed).
67    pub heading: String,
68    /// Occurrences in the prior/baseline document.
69    pub prior_count: usize,
70    /// Occurrences in the current/candidate document.
71    pub current_count: usize,
72}
73
74/// Normalize a line to its `### Re:` heading identity, or `None` if it is not a
75/// response heading. Strips the working-tree-only ` (HEAD)` annotation so a
76/// freshly-annotated heading is not mistaken for a different block.
77fn response_heading_identity(line: &str) -> Option<String> {
78    let trimmed = line.trim();
79    if !trimmed.starts_with("### Re:") {
80        return None;
81    }
82    let without_head = trimmed.strip_suffix(" (HEAD)").unwrap_or(trimmed);
83    Some(without_head.trim_end().to_string())
84}
85
86/// Count `### Re:` heading occurrences per normalized identity in `doc`.
87fn response_heading_counts(doc: &str) -> BTreeMap<String, usize> {
88    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
89    for line in doc.lines() {
90        if let Some(id) = response_heading_identity(line) {
91            *counts.entry(id).or_insert(0) += 1;
92        }
93    }
94    counts
95}
96
97/// Detect the `#ipcfullprompt` corruption shape by comparing the committed
98/// `prior` document against the live `current` (candidate) document.
99///
100/// Returns one finding per prior `### Re:` heading whose count dropped to zero
101/// (deleted) or rose above its prior count (duplicated). Headings that only
102/// appear in `current` are ignored — that is the expected new response, not
103/// corruption. Deterministic and side-effect free.
104pub fn detect_response_block_corruption(prior: &str, current: &str) -> Vec<IpcCorruptionFinding> {
105    let prior_counts = response_heading_counts(prior);
106    if prior_counts.is_empty() {
107        return Vec::new();
108    }
109    let current_counts = response_heading_counts(current);
110
111    let mut findings = Vec::new();
112    for (heading, &prior_count) in &prior_counts {
113        let current_count = current_counts.get(heading).copied().unwrap_or(0);
114        if current_count == 0 {
115            findings.push(IpcCorruptionFinding {
116                kind: IpcCorruptionKind::ResponseDeleted,
117                heading: heading.clone(),
118                prior_count,
119                current_count,
120            });
121        } else if current_count > prior_count {
122            findings.push(IpcCorruptionFinding {
123                kind: IpcCorruptionKind::ResponseDuplicated,
124                heading: heading.clone(),
125                prior_count,
126                current_count,
127            });
128        }
129    }
130    findings
131}
132
133/// Canonical structural component markers that must appear at most once in a
134/// healthy template document. More than one — on its own line — is the full-tail
135/// duplication shape of `#ipcfullprompt`: the editor copied the post-exchange
136/// scaffold (close marker + Queue/Backlog/Icebox) around a live, in-progress
137/// prompt, producing two `<!-- /agent:exchange -->` markers.
138const STRUCTURAL_MARKERS: &[&str] = &[
139    "<!-- /agent:exchange -->",
140    "<!-- agent:queue -->",
141    "<!-- /agent:queue -->",
142    "<!-- agent:backlog -->",
143    "<!-- /agent:backlog -->",
144    "<!-- agent:icebox -->",
145    "<!-- /agent:icebox -->",
146];
147
148/// Detect a duplicated structural scaffold in `current` (a self-check; no prior
149/// needed). Counts only lines that, when trimmed, exactly equal a canonical
150/// structural marker — so an inline mention inside response prose does not
151/// inflate the count — and returns one finding per marker that appears more than
152/// once. This is the `#ipcfullprompt` full-tail-duplication signature that
153/// `detect_response_block_corruption` (which keys on `### Re:` headings) misses.
154pub fn detect_duplicated_scaffold(current: &str) -> Vec<IpcCorruptionFinding> {
155    let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
156    for line in current.lines() {
157        let trimmed = line.trim();
158        for marker in STRUCTURAL_MARKERS {
159            if trimmed == *marker {
160                *counts.entry(*marker).or_insert(0) += 1;
161            }
162        }
163    }
164    counts
165        .into_iter()
166        .filter(|(_, count)| *count > 1)
167        .map(|(marker, count)| IpcCorruptionFinding {
168            kind: IpcCorruptionKind::ScaffoldDuplicated,
169            heading: marker.to_string(),
170            prior_count: 1,
171            current_count: count,
172        })
173        .collect()
174}
175
176/// Render findings as a compact single-line summary for ops.log. Truncates each
177/// heading so a long topic cannot blow up the log line.
178pub fn summarize_findings(findings: &[IpcCorruptionFinding]) -> String {
179    let deleted = findings
180        .iter()
181        .filter(|f| f.kind == IpcCorruptionKind::ResponseDeleted)
182        .count();
183    let duplicated = findings
184        .iter()
185        .filter(|f| f.kind == IpcCorruptionKind::ResponseDuplicated)
186        .count();
187    let scaffold = findings
188        .iter()
189        .filter(|f| f.kind == IpcCorruptionKind::ScaffoldDuplicated)
190        .count();
191    let details: Vec<String> = findings
192        .iter()
193        .map(|f| {
194            let mut h = f.heading.clone();
195            if h.len() > 80 {
196                // Floor to the nearest char boundary so a multi-byte glyph
197                // (headings carry `—`/`→`/Unicode) straddling byte 80 cannot
198                // panic `String::truncate`. UTF-8-safe per the route/IPC
199                // diagnostic-trimming invariant.
200                let mut end = 80;
201                while end > 0 && !h.is_char_boundary(end) {
202                    end -= 1;
203                }
204                h.truncate(end);
205            }
206            format!(
207                "{}({}:{}->{})",
208                f.kind.label(),
209                h,
210                f.prior_count,
211                f.current_count
212            )
213        })
214        .collect();
215    format!(
216        "deleted={} duplicated={} scaffold_duplicated={} details=[{}]",
217        deleted,
218        duplicated,
219        scaffold,
220        details.join("; ")
221    )
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    const PRIOR: &str = concat!(
229        "<!-- agent:exchange -->\n",
230        "### Re: first topic — opus-4-8\n",
231        "First answer.\n",
232        "### Re: second topic — opus-4-8\n",
233        "Second answer.\n",
234        "<!-- /agent:exchange -->\n",
235    );
236
237    #[test]
238    fn detect_flags_deleted_prior_response() {
239        // The second response block is gone from the live buffer.
240        let current = concat!(
241            "<!-- agent:exchange -->\n",
242            "### Re: first topic — opus-4-8\n",
243            "First answer.\n",
244            "<!-- /agent:exchange -->\n",
245        );
246        let findings = detect_response_block_corruption(PRIOR, current);
247        assert_eq!(findings.len(), 1, "exactly one deletion: {findings:?}");
248        assert_eq!(findings[0].kind, IpcCorruptionKind::ResponseDeleted);
249        assert_eq!(findings[0].heading, "### Re: second topic — opus-4-8");
250        assert_eq!(findings[0].prior_count, 1);
251        assert_eq!(findings[0].current_count, 0);
252    }
253
254    #[test]
255    fn detect_flags_duplicated_prior_response() {
256        // The first response block appears twice — the full-tail duplication shape.
257        let current = concat!(
258            "<!-- agent:exchange -->\n",
259            "### Re: first topic — opus-4-8\n",
260            "First answer.\n",
261            "### Re: first topic — opus-4-8\n",
262            "First answer.\n",
263            "### Re: second topic — opus-4-8\n",
264            "Second answer.\n",
265            "<!-- /agent:exchange -->\n",
266        );
267        let findings = detect_response_block_corruption(PRIOR, current);
268        assert_eq!(findings.len(), 1, "exactly one duplication: {findings:?}");
269        assert_eq!(findings[0].kind, IpcCorruptionKind::ResponseDuplicated);
270        assert_eq!(findings[0].heading, "### Re: first topic — opus-4-8");
271        assert_eq!(findings[0].prior_count, 1);
272        assert_eq!(findings[0].current_count, 2);
273    }
274
275    #[test]
276    fn detect_ignores_new_response_block() {
277        // A brand-new response added this cycle is expected growth, not corruption.
278        let current = concat!(
279            "<!-- agent:exchange -->\n",
280            "### Re: first topic — opus-4-8\n",
281            "First answer.\n",
282            "### Re: second topic — opus-4-8\n",
283            "Second answer.\n",
284            "### Re: third topic — opus-4-8\n",
285            "Third answer.\n",
286            "<!-- /agent:exchange -->\n",
287        );
288        assert!(
289            detect_response_block_corruption(PRIOR, current).is_empty(),
290            "new response heading must not be flagged"
291        );
292    }
293
294    #[test]
295    fn detect_ignores_head_annotation_difference() {
296        // The live buffer marks the newest heading with `(HEAD)`; identity must
297        // ignore it so no spurious delete+add pair is reported.
298        let current = concat!(
299            "<!-- agent:exchange -->\n",
300            "### Re: first topic — opus-4-8\n",
301            "First answer.\n",
302            "### Re: second topic — opus-4-8 (HEAD)\n",
303            "Second answer.\n",
304            "<!-- /agent:exchange -->\n",
305        );
306        assert!(
307            detect_response_block_corruption(PRIOR, current).is_empty(),
308            "(HEAD) annotation must not read as corruption"
309        );
310    }
311
312    #[test]
313    fn detect_clean_document_has_no_findings() {
314        assert!(detect_response_block_corruption(PRIOR, PRIOR).is_empty());
315    }
316
317    #[test]
318    fn detect_no_prior_headings_is_empty() {
319        // No prior response blocks → nothing to protect, no findings.
320        let prior = "<!-- agent:exchange -->\njust a prompt\n<!-- /agent:exchange -->\n";
321        let current =
322            "<!-- agent:exchange -->\n### Re: x — opus-4-8\nhi\n<!-- /agent:exchange -->\n";
323        assert!(detect_response_block_corruption(prior, current).is_empty());
324    }
325
326    // #ipcfullprompt-recur2: the full-tail duplication shape captured live in
327    // tasks/professional/brandon-cinquegrana.md 2026-05-29 — the editor copied the
328    // post-exchange scaffold around an in-progress prompt, leaving two
329    // `<!-- /agent:exchange -->` markers. `detect_response_block_corruption` misses
330    // this (no `### Re:` was duplicated); `detect_duplicated_scaffold` must catch it.
331    #[test]
332    fn detect_duplicated_scaffold_flags_two_exchange_close_markers() {
333        let corrupted = concat!(
334            "<!-- agent:exchange -->\n",
335            "### Re: prior — opus-4-8\nAnswer.\n",
336            "<!-- agent:boundary:709a41ae -->\n",
337            "Is the issue still happening?\nCan it be re\n",
338            "<!-- /agent:exchange -->\n",
339            "###\n",
340            "## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
341            "## Backlog\n<!-- agent:backlog -->\n<!-- /agent:backlog -->\n",
342            "## Icebox\n<!-- agent:icebox -->\n<!-- /agent:icebox -->\n",
343            "Can it be rep11ro\n",
344            "<!-- /agent:exchange -->\n",
345            "###\n",
346            "## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
347        );
348        let findings = detect_duplicated_scaffold(corrupted);
349        assert!(
350            findings
351                .iter()
352                .any(|f| f.kind == IpcCorruptionKind::ScaffoldDuplicated
353                    && f.heading == "<!-- /agent:exchange -->"
354                    && f.current_count == 2),
355            "two exchange close markers must be flagged: {findings:?}"
356        );
357        // The duplicated queue scaffold is also flagged.
358        assert!(
359            findings
360                .iter()
361                .any(|f| f.heading == "<!-- agent:queue -->" && f.current_count == 2)
362        );
363    }
364
365    #[test]
366    fn detect_duplicated_scaffold_clean_doc_has_no_findings() {
367        let clean = concat!(
368            "<!-- agent:exchange -->\n",
369            "### Re: x — opus-4-8\nAnswer.\n",
370            "<!-- /agent:exchange -->\n",
371            "## Queue\n<!-- agent:queue -->\n<!-- /agent:queue -->\n",
372            "## Backlog\n<!-- agent:backlog -->\n<!-- /agent:backlog -->\n",
373        );
374        assert!(detect_duplicated_scaffold(clean).is_empty());
375    }
376
377    #[test]
378    fn detect_duplicated_scaffold_ignores_inline_marker_mentions() {
379        // A marker mentioned inline inside prose (not on its own line) must not
380        // inflate the count — only standalone marker lines are structural.
381        let doc = concat!(
382            "<!-- agent:exchange -->\n",
383            "### Re: x — opus-4-8\n",
384            "The close marker is written `<!-- /agent:exchange -->` inline here.\n",
385            "<!-- /agent:exchange -->\n",
386        );
387        assert!(
388            detect_duplicated_scaffold(doc).is_empty(),
389            "inline mention must not count as a structural duplicate"
390        );
391    }
392
393    #[test]
394    fn summarize_is_compact_and_truncates() {
395        let findings = vec![
396            IpcCorruptionFinding {
397                kind: IpcCorruptionKind::ResponseDeleted,
398                heading: "### Re: a".to_string(),
399                prior_count: 1,
400                current_count: 0,
401            },
402            IpcCorruptionFinding {
403                kind: IpcCorruptionKind::ResponseDuplicated,
404                heading: "### Re: b".to_string(),
405                prior_count: 1,
406                current_count: 3,
407            },
408        ];
409        let s = summarize_findings(&findings);
410        assert!(s.contains("deleted=1"), "{s}");
411        assert!(s.contains("duplicated=1"), "{s}");
412        assert!(s.contains("response_deleted(### Re: a:1->0)"), "{s}");
413        assert!(s.contains("response_duplicated(### Re: b:1->3)"), "{s}");
414        assert!(!s.contains('\n'), "summary must be single-line: {s}");
415    }
416
417    #[test]
418    fn summarize_findings_truncates_multibyte_heading_without_panic() {
419        // Regression: a long heading whose multi-byte glyph (`—`, em dash, 3
420        // bytes) straddles byte 80 used to panic `String::truncate` with
421        // `assertion failed: self.is_char_boundary(new_len)` during the live
422        // IPC-drift corruption summary, aborting `finalize` recovery.
423        // "### Re: " (8 bytes) + 71*'x' (-> byte 79) + "—" (3 bytes, 79..82) so
424        // byte 80 lands inside the em dash, the worst-case truncation boundary.
425        let heading = format!("### Re: {}— opus-4-8", "x".repeat(71));
426        assert!(heading.len() > 80);
427        assert!(!heading.is_char_boundary(80));
428        let findings = vec![IpcCorruptionFinding {
429            kind: IpcCorruptionKind::ResponseDeleted,
430            heading,
431            prior_count: 1,
432            current_count: 0,
433        }];
434        let s = summarize_findings(&findings);
435        assert!(s.contains("deleted=1"), "{s}");
436        assert!(!s.contains('\n'), "summary must be single-line: {s}");
437    }
438}