Skip to main content

agent_doc_core/
cell_doc.rs

1//! Per-cell document projection (`#qcellmerge1` groundwork).
2//!
3//! agent-doc's shipped [`crdt::merge`](crate::crdt::merge) reconciles concurrent
4//! edits with a **whole-document** CRDT merge. When two sides touch unrelated
5//! cells the yrs text merge can still splice content across component / item
6//! boundaries — the recurring corruption class where, e.g., a queue head's text
7//! ends up inside an exchange block.
8//!
9//! This module is **additive groundwork** for the eventual per-cell cutover. It
10//! does *not* change `crdt::merge`. Instead it:
11//!
12//! 1. Projects a parsed agent-doc document into an **ordered, keyed, per-component
13//!    model** ([`project_document`]): for each component occurrence an ordered
14//!    `Vec<(NodeKey, ItemValue)>` keyed by the stable
15//!    `component:occurrence:item-id:index` node-key scheme.
16//! 2. Computes **minimal per-item ops** between two document revisions
17//!    ([`diff_document`]) using the lazily [`reconcile`] LIS keyed-diff — so a
18//!    structural edit becomes a handful of `Insert`/`Remove`/`Move`/`Update`
19//!    ops scoped to one component, never a whole-component (or whole-document)
20//!    replace.
21//! 3. Drives a reactive [`CellTree`]/[`CellMap`] representation
22//!    ([`CellDocTree`]) so an edit to one item invalidates only that item's
23//!    value cell — the core anti-corruption property that the eventual
24//!    per-cell merge needs.
25//!
26//! ## Reuse of the existing parse + key scheme
27//!
28//! - Component framing is parsed with [`crate::component::parse`] (the single
29//!   document-component parser).
30//! - Item segmentation reuses the crdt module's keyed-child splitters
31//!   ([`crate::crdt::split_list_children`] /
32//!   [`crate::crdt::split_exchange_children`]) and their durable
33//!   `list_item_key` identity (`id:<id>` / `txt:<normalized>`),
34//!   so the same items that the live merge keys on are the items projected
35//!   here. The crdt module's `PREAMBLE_KEY` leading-text child is preserved.
36//!
37//! The exposed [`NodeKey`] is the `component:occurrence:item-id:index` string
38//! used throughout `turn_scope` / `op_log` (e.g. `queue:0:beta:0`), where
39//! `item-id` is the durable child key.
40
41use lazily::{
42    CellMap, CellTree, Context, DiffOp, SemTree, TextCrdt, apply_to_map, apply_to_tree, reconcile,
43};
44
45use crate::component::{self, Component};
46use crate::crdt::{
47    EditorOp, PREAMBLE_KEY, is_list_component, replay_editor_ops, split_exchange_children,
48    split_list_children,
49};
50use crate::queue_item_lifecycle::QueueItemLifecycle;
51
52/// Environment variable that gates the live CRDT merge per-cell
53/// 3-way path ([`merge_3way`], routed from [`crate::crdt::merge_by_component`]).
54/// **Default ON** with an env kill-switch: per-cell merge is the production
55/// default. Only an explicit falsy value (`0`/`false`/`off`/`no`) turns it off;
56/// absent / empty / any other value ⇒ ON. See [`cell_merge_enabled`].
57pub const CELL_MERGE_ENV: &str = "AGENT_DOC_CELL_MERGE";
58
59/// Process-global serialization lock for tests that mutate the
60/// `AGENT_DOC_CELL_MERGE` env var. The env var is process-wide, so flag-toggling
61/// tests across modules (`cell_doc`, `crdt`) must serialize through this single
62/// lock to avoid racing each other. `pub(crate)` so sibling test modules share it.
63#[cfg(test)]
64pub(crate) static CELL_MERGE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
65
66/// Environment variable that opts a same-cell *both-sides-changed* divergence
67/// into the op-level [`TextCrdt`] 3-way merge (`#qcellmerge1` opcapture rung).
68///
69/// **Sub-gate of [`CELL_MERGE_ENV`], default ON** with an env kill-switch. With
70/// the per-cell merge seam ON ([`cell_merge_enabled`]), the `(both changed)`
71/// branch first attempts a deterministic character-granular 3-way merge: two
72/// edits to DISJOINT regions of one cell converge with BOTH preserved and NO
73/// conflict; a true same-region overlap still records a [`CellConflict`] and
74/// applies the policy. An explicit falsy value (`0`/`false`/`off`/`no`) restores
75/// the legacy policy-only behavior. Forced OFF when the master switch is
76/// explicitly disabled. See [`cell_merge_opcapture_enabled`] / [`op_level_merge`].
77pub const CELL_MERGE_OPCAPTURE_ENV: &str = "AGENT_DOC_CELL_MERGE_OPCAPTURE";
78
79/// Environment variable that opts a recorded same-region [`CellConflict`] into an
80/// **operator-visible in-band conflict marker** in the merged document
81/// (`#qcellconflict` conflict-surfacing rung).
82///
83/// **Sub-gate of [`CELL_MERGE_ENV`], default ON** with an env kill-switch. With
84/// the per-cell merge seam ON, each genuine same-region content conflict appends
85/// a deterministic, framing-safe HTML-comment conflict block to the conflicted
86/// node's value, surfacing BOTH versions verbatim while keeping ours as the
87/// active/structural text (never a fabricated blend). An explicit falsy value
88/// (`0`/`false`/`off`/`no`) restores the legacy behavior: the conflict is
89/// resolved ours-wins and only logged to stderr ([`log_conflicts`]) — the
90/// operator never sees the losing side in the document. Forced OFF when the
91/// master switch is explicitly disabled. A lawful lifecycle join is never a
92/// conflict and never marked. See [`surface_conflict_markers`] / [`conflict_marker`].
93pub const CELL_MERGE_CONFLICT_MARKERS_ENV: &str = "AGENT_DOC_CELL_MERGE_CONFLICT_MARKERS";
94
95/// Parse an *explicitly* falsy on/off env value (`0`/`false`/`off`/`no`,
96/// case/space-insensitive). The kill-switch counterpart for the default-ON
97/// per-cell merge gates: an absent var, an empty value, or any unrecognized
98/// value is NOT falsy — only the recognized kill-switch tokens disable a gate.
99fn env_falsy(name: &str) -> bool {
100    match std::env::var(name) {
101        Ok(v) => {
102            let v = v.trim().to_ascii_lowercase();
103            matches!(v.as_str(), "0" | "false" | "off" | "no")
104        }
105        Err(_) => false,
106    }
107}
108
109fn env_truthy(name: &str) -> bool {
110    match std::env::var(name) {
111        Ok(v) => {
112            let v = v.trim().to_ascii_lowercase();
113            matches!(v.as_str(), "1" | "true" | "on" | "yes")
114        }
115        Err(_) => false,
116    }
117}
118
119/// Whether the per-cell 3-way merge routing seam is enabled. **Default ON** with
120/// an env kill-switch: per-cell merge is the production default and only an
121/// explicit falsy `AGENT_DOC_CELL_MERGE` (`0`/`false`/`off`/`no`, case/space-
122/// insensitive) turns it off. Absent / empty / any non-falsy value ⇒ ON.
123pub fn cell_merge_enabled() -> bool {
124    !env_falsy(CELL_MERGE_ENV)
125}
126
127/// Whether the op-level [`TextCrdt`] 3-way merge for same-cell both-sides edits is
128/// enabled (`#qcellmerge1` opcapture). **Default ON** with an env kill-switch:
129/// only an explicit falsy `AGENT_DOC_CELL_MERGE_OPCAPTURE` turns it off. As a
130/// sub-feature of the per-cell merge stack, it is also forced OFF whenever the
131/// master switch [`cell_merge_enabled`] is explicitly disabled — disabling the
132/// master kills the whole stack regardless of the sub-gate value.
133pub fn cell_merge_opcapture_enabled() -> bool {
134    cell_merge_enabled() && !env_falsy(CELL_MERGE_OPCAPTURE_ENV)
135}
136
137/// Whether recorded same-region [`CellConflict`]s are surfaced as operator-visible
138/// in-band conflict markers (`#qcellconflict`). **Default OFF** as of
139/// #provauth1/#provauth4: same-cell conflicts now resolve by component
140/// authorship (operator-owned cells keep the operator's edit, agent-owned cells
141/// keep the binary's — see [`component_conflict_policy`]), so an in-band
142/// `<<<<<<<` marker would re-surface an operator action as a suspicious conflict,
143/// which is exactly what the provenance principle forbids. The marker machinery
144/// is retained behind an explicit opt-in (`AGENT_DOC_CELL_MERGE_CONFLICT_MARKERS`
145/// truthy) for debugging/inspection only. As a sub-feature of the per-cell merge
146/// stack it is still forced OFF whenever [`cell_merge_enabled`] is disabled.
147pub fn cell_merge_conflict_markers_enabled() -> bool {
148    cell_merge_enabled() && env_truthy(CELL_MERGE_CONFLICT_MARKERS_ENV)
149}
150
151/// The stable per-item identity: `component:occurrence:item-id:index`, matching
152/// the node-key scheme used by `turn_scope`/`op_log` (e.g. `queue:0:beta:0`).
153///
154/// `item-id` is the durable child key (`id:<id>` or `txt:<normalized>`), which
155/// is stable across strike / text edits; `index` is the child's position within
156/// the occurrence at projection time and is informational only (the durable
157/// identity is `component:occurrence:item-id`, so a pure reorder keeps the key
158/// minus its trailing index — see [`node_key_identity`]).
159pub type NodeKey = String;
160
161/// An item's content: the exact source slice of the keyed child (lossless —
162/// concatenating an occurrence's item values reproduces its component body).
163pub type ItemValue = String;
164
165/// Build the `component:occurrence:item-id:index` node key.
166fn make_node_key(component: &str, occurrence: usize, item_id: &str, index: usize) -> NodeKey {
167    format!("{component}:{occurrence}:{item_id}:{index}")
168}
169
170/// True when an occurrence projected to the reserved single whole-body item
171/// (unsplittable / non-item component — `split_keyed_children` returned `None`)
172/// rather than real keyed children. The keyed compose is only sound when ours
173/// and theirs agree on splittability; a body-only-vs-keyed mismatch mirrors the
174/// legacy `reconcile_component_body` `split(...)?` guard (either side
175/// unsplittable ⇒ whole-component leaf merge).
176fn is_body_only(occ: &ComponentOccurrence) -> bool {
177    occ.items.len() == 1
178        && occ.items[0].0 == make_node_key(&occ.component, occ.occurrence, "body", 0)
179}
180
181/// The reorder-stable identity of a node key: everything but the trailing
182/// `:index`. Two revisions of the same item (same component/occurrence/item-id)
183/// share this even if the item moved, so the keyed diff matches them.
184pub fn node_key_identity(key: &str) -> &str {
185    match key.rfind(':') {
186        Some(i) => &key[..i],
187        None => key,
188    }
189}
190
191/// One component occurrence projected into an ordered keyed item sequence.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ComponentOccurrence {
194    /// Component name (`queue`, `backlog`, `exchange`, …).
195    pub component: String,
196    /// 0-based occurrence index of this component name within the document.
197    pub occurrence: usize,
198    /// Ordered `(node_key, item_value)` pairs for this occurrence's keyed items.
199    /// Keyed by [`NodeKey`]; values are the lossless source slices.
200    pub items: Vec<(NodeKey, ItemValue)>,
201}
202
203impl ComponentOccurrence {
204    /// The diff-stable keyed view (`identity` → value), dropping the positional
205    /// `:index` suffix so a pure reorder is matched as a `Move`, not
206    /// remove+insert. This is what feeds the lazily [`reconcile`].
207    fn keyed_for_diff(&self) -> Vec<(String, ItemValue)> {
208        self.items
209            .iter()
210            .map(|(k, v)| (node_key_identity(k).to_string(), v.clone()))
211            .collect()
212    }
213}
214
215/// Per-component diff between two document revisions: the component identity plus
216/// the minimal lazily op set for its item level.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ComponentDiff {
219    /// Component name.
220    pub component: String,
221    /// 0-based occurrence index.
222    pub occurrence: usize,
223    /// Minimal per-item ops (`Insert`/`Remove`/`Move`/`Update`) keyed by the
224    /// reorder-stable node-key identity (`component:occurrence:item-id`).
225    pub ops: Vec<DiffOp<String, ItemValue>>,
226}
227
228impl ComponentDiff {
229    /// True when this component has no item-level changes (no cross-cell splice
230    /// touched it).
231    pub fn is_empty(&self) -> bool {
232        self.ops.is_empty()
233    }
234}
235
236/// Project a parsed document body into ordered keyed item sequences, one per
237/// component occurrence.
238///
239/// Components whose body is not item-structured (not a list component and not
240/// `exchange`, or with no splittable / uniquely-keyed children) are projected as
241/// a single whole-component item keyed `component:occurrence:body:0` — so the
242/// diff degrades to a whole-component `Update` (still scoped to that occurrence,
243/// never cross-component) exactly where the live merge would fall back to the
244/// flat path.
245pub fn project_document(doc: &str) -> Vec<ComponentOccurrence> {
246    let components = match component::parse(doc) {
247        Ok(c) => c,
248        Err(_) => return Vec::new(),
249    };
250    let mut occurrence_counts: std::collections::HashMap<String, usize> =
251        std::collections::HashMap::new();
252    let mut out = Vec::with_capacity(components.len());
253    for comp in &components {
254        let occ = occurrence_counts.entry(comp.name.clone()).or_insert(0);
255        let occurrence = *occ;
256        *occ += 1;
257        out.push(project_component(doc, comp, occurrence));
258    }
259    out
260}
261
262/// Project a single component occurrence into ordered keyed items.
263fn project_component(doc: &str, comp: &Component, occurrence: usize) -> ComponentOccurrence {
264    let body = comp.content(doc);
265    let children = split_keyed_children(&comp.name, body);
266    let items = match children {
267        Some(children) => children
268            .into_iter()
269            .enumerate()
270            .map(|(index, (key, text))| (make_node_key(&comp.name, occurrence, &key, index), text))
271            .collect(),
272        // Unsplittable / non-item component: one whole-body item. The reserved
273        // `body` item-id never collides with a real child key (`id:`/`txt:`/
274        // PREAMBLE) so a body-only component still gets a stable key.
275        None => vec![(
276            make_node_key(&comp.name, occurrence, "body", 0),
277            body.to_string(),
278        )],
279    };
280    ComponentOccurrence {
281        component: comp.name.clone(),
282        occurrence,
283        items,
284    }
285}
286
287// ---------------------------------------------------------------------------
288// Op-capture rung (`#qcellmerge1` op routing): span-aware projection.
289//
290// `project_document` exposes `(NodeKey, ItemValue)` without byte spans, so a
291// captured absolute-offset [`EditorOp`] cannot be located in a cell. This rung
292// adds a parallel *span-aware* projection ([`project_document_spans`]) that, for
293// each keyed item, records its **byte span** `(start, end)` in the FULL source
294// document. A component body sits at `&doc[comp.open_end..comp.close_start]`
295// (see `component::Component::content`), and the splitters are lossless
296// (`children.map(text).collect() == body`), so each item's full-doc span is
297// `comp.open_end + accumulated_child_len .. + item_len`. An absolute op offset
298// then resolves to the unique containing item — or to none (structural framing /
299// boundary-crossing), which forces a conservative fallback.
300// ---------------------------------------------------------------------------
301
302/// A half-open byte span `[start, end)` into the full source document.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct ByteSpan {
305    /// Inclusive start byte offset in the full document.
306    pub start: usize,
307    /// Exclusive end byte offset in the full document.
308    pub end: usize,
309}
310
311impl ByteSpan {
312    /// True when `offset` lies within `[start, end)`.
313    fn contains_offset(&self, offset: usize) -> bool {
314        offset >= self.start && offset < self.end
315    }
316
317    /// True when the whole half-open range `[lo, hi)` lies within this span
318    /// (`lo == hi` permitted at any in-span boundary — an empty edit point).
319    fn contains_range(&self, lo: usize, hi: usize) -> bool {
320        lo >= self.start && hi <= self.end && lo <= hi
321    }
322}
323
324/// One projected item plus its full-document byte span. `node_key` /  `value`
325/// mirror [`ComponentOccurrence::items`]; `span` is the lossless source range of
326/// `value` in the full document (so concatenating an occurrence's spans in order
327/// reproduces its body, and the spans tile the body with no gaps).
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct ItemSpan {
330    /// Component name (`queue`, `exchange`, …).
331    pub component: String,
332    /// 0-based occurrence index of that component name.
333    pub occurrence: usize,
334    /// Full `component:occurrence:item-id:index` node key.
335    pub node_key: NodeKey,
336    /// Reorder-stable identity (`component:occurrence:item-id`).
337    pub identity: String,
338    /// The item's exact source text.
339    pub value: ItemValue,
340    /// The item's byte span in the FULL document.
341    pub span: ByteSpan,
342}
343
344/// Project a document into span-aware items, one entry per keyed item across all
345/// component occurrences, in document order. Back-compatible companion to
346/// [`project_document`] (which the 26 existing callers/tests keep using).
347///
348/// Only the **item interiors** are projected: the component open/close markers,
349/// frontmatter, and any inter-component interstitial text are deliberately NOT
350/// covered, so an op landing in that structural framing resolves to no item and
351/// forces the conservative fallback in [`route_ops_to_cells`].
352///
353/// Returns `None` on a parse error (caller falls back).
354pub fn project_document_spans(doc: &str) -> Option<Vec<ItemSpan>> {
355    let components = component::parse(doc).ok()?;
356    // Top-level components only (mirror `parse_doc_nodes` / `segment_into_nodes`).
357    let top: Vec<&Component> = components
358        .iter()
359        .filter(|c| {
360            !components.iter().any(|o| {
361                !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
362            })
363        })
364        .collect();
365    let mut top = top;
366    top.sort_by_key(|c| c.open_start);
367
368    let mut occurrence_counts: std::collections::HashMap<String, usize> =
369        std::collections::HashMap::new();
370    let mut out = Vec::new();
371    for comp in top {
372        let occ = occurrence_counts.entry(comp.name.clone()).or_insert(0);
373        let occurrence = *occ;
374        *occ += 1;
375        // Body sits at [open_end, close_start); each item span is body-relative
376        // offset translated by `open_end` into the full document.
377        let body_start = comp.open_end;
378        let body = comp.content(doc);
379        let occ_proj = project_component(doc, comp, occurrence);
380        let mut cursor = body_start;
381        for (node_key, value) in occ_proj.items {
382            let start = cursor;
383            let end = start + value.len();
384            cursor = end;
385            out.push(ItemSpan {
386                component: comp.name.clone(),
387                occurrence,
388                identity: node_key_identity(&node_key).to_string(),
389                node_key,
390                value,
391                span: ByteSpan { start, end },
392            });
393        }
394        // Lossless invariant: the item spans must exactly tile the body. If the
395        // splitter ever drifted from `content`, decline (caller falls back) — we
396        // never want an op routed against a mis-tiled body.
397        debug_assert_eq!(cursor, body_start + body.len(), "item spans must tile body");
398        if cursor != body_start + body.len() {
399            return None;
400        }
401    }
402    Some(out)
403}
404
405/// Split a component body into `(item_key, text)` pairs reusing the crdt module's
406/// keyed-child segmentation. Returns `None` (caller falls back to a single
407/// whole-body item) when the body is not item-structured or its keys are not
408/// unique — mirroring the live merge's fall-back-to-flat condition.
409fn split_keyed_children(name: &str, body: &str) -> Option<Vec<(String, String)>> {
410    let children = if name == "exchange" {
411        split_exchange_children(body)
412    } else if is_list_component(name) {
413        split_list_children(body)
414    } else {
415        None
416    }?;
417    // Sanity: unique keys, else keyed reconciliation is unsound (two identical
418    // free-text items). Disambiguate the PREAMBLE child, which is always first.
419    let mut seen = std::collections::HashSet::new();
420    let mut pairs = Vec::with_capacity(children.len());
421    for child in &children {
422        let key = if child.key == PREAMBLE_KEY {
423            "preamble".to_string()
424        } else {
425            child.key.clone()
426        };
427        if !seen.insert(key.clone()) {
428            return None;
429        }
430        pairs.push((key, child.text.clone()));
431    }
432    Some(pairs)
433}
434
435/// The minimal per-item op set between two revisions of one component
436/// occurrence. Pure groundwork: a structural edit becomes minimal per-item ops
437/// (the per-cell-merge enabler), not a whole-component replace.
438fn diff_occurrence(old: &ComponentOccurrence, new: &ComponentOccurrence) -> ComponentDiff {
439    let ops = reconcile(&old.keyed_for_diff(), &new.keyed_for_diff());
440    ComponentDiff {
441        component: new.component.clone(),
442        occurrence: new.occurrence,
443        ops,
444    }
445}
446
447/// Diff two document revisions into per-component minimal op sets.
448///
449/// Matches component occurrences by `(component, occurrence)`. A component that
450/// exists in only one revision yields a diff that inserts / removes its items.
451/// Crucially, ops are **scoped per component occurrence**: an edit in component
452/// A produces no ops for component B (no cross-cell splice). Components with no
453/// item-level change are omitted (use the full vector if you need empties).
454pub fn diff_document(old_doc: &str, new_doc: &str) -> Vec<ComponentDiff> {
455    let old = project_document(old_doc);
456    let new = project_document(new_doc);
457
458    let mut old_by_key: std::collections::HashMap<(String, usize), &ComponentOccurrence> =
459        std::collections::HashMap::new();
460    for occ in &old {
461        old_by_key.insert((occ.component.clone(), occ.occurrence), occ);
462    }
463    let mut new_keys: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
464
465    let mut diffs = Vec::new();
466
467    // Components present in `new` (matched or inserted).
468    for occ in &new {
469        new_keys.insert((occ.component.clone(), occ.occurrence));
470        let diff = match old_by_key.get(&(occ.component.clone(), occ.occurrence)) {
471            Some(old_occ) => diff_occurrence(old_occ, occ),
472            None => {
473                // Wholly new occurrence → insert every item.
474                let empty = ComponentOccurrence {
475                    component: occ.component.clone(),
476                    occurrence: occ.occurrence,
477                    items: Vec::new(),
478                };
479                diff_occurrence(&empty, occ)
480            }
481        };
482        if !diff.is_empty() {
483            diffs.push(diff);
484        }
485    }
486
487    // Components present only in `old` → remove every item.
488    for occ in &old {
489        if !new_keys.contains(&(occ.component.clone(), occ.occurrence)) {
490            let empty = ComponentOccurrence {
491                component: occ.component.clone(),
492                occurrence: occ.occurrence,
493                items: Vec::new(),
494            };
495            let diff = diff_occurrence(occ, &empty);
496            if !diff.is_empty() {
497                diffs.push(diff);
498            }
499        }
500    }
501
502    diffs
503}
504
505// ---------------------------------------------------------------------------
506// Phase 2 (`#qcellmerge1` cutover): 3-way per-cell merge.
507//
508// `crdt::merge` is a *3-way* merge: `merge(base, ours, theirs)`. The 2-way
509// `diff_document` above projects/diffs two revisions; the cutover composes TWO
510// keyed diffs (base→ours and base→theirs) per component occurrence into one
511// merged item list, surfacing a real conflict when the SAME item is updated to
512// different text on both sides (`#qnodemerge5`: never fabricate a blend).
513// ---------------------------------------------------------------------------
514
515/// What kind of same-item-both-sides divergence produced a [`CellConflict`].
516///
517/// Only a [`ConflictKind::Content`] divergence is a *genuine* conflict — two
518/// sides edited the SAME item to different text at the SAME lawful lifecycle
519/// level (both `Live`, or both `Struck`), so neither side's intent subsumes the
520/// other and a deterministic [`ConflictPolicy`] must pick a winner. A
521/// strike-vs-unstrike is NOT a conflict (it is a lawful `Live < Struck`
522/// lifecycle join, struck wins) and is never recorded here.
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
524pub enum ConflictKind {
525    /// Both sides at the same lifecycle level edited the item to different text.
526    #[default]
527    Content,
528}
529
530/// A surfaced 3-way conflict: the same keyed item was updated to different text
531/// on both sides at the SAME lawful lifecycle level. The merge applies a
532/// deterministic policy (ours-wins, see [`ConflictPolicy`]) and records the
533/// conflict here so callers never silently lose the losing side.
534///
535/// Lifecycle joins (a strike vs. a stale un-strike) are *not* conflicts — they
536/// resolve deterministically to the struck side (`Live < Struck`) and are
537/// excluded from this list. Only genuine same-level content disagreements count.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct CellConflict {
540    /// Component name (`queue`, `exchange`, …) the conflict occurred in.
541    pub component: String,
542    /// 0-based occurrence index of that component.
543    pub occurrence: usize,
544    /// Reorder-stable item identity (`component:occurrence:item-id`).
545    pub identity: String,
546    /// The kind of divergence (currently always [`ConflictKind::Content`]; a
547    /// lifecycle join is never recorded as a conflict).
548    pub kind: ConflictKind,
549    /// The value chosen by the merge policy (kept in `merged_text`).
550    pub chosen: ItemValue,
551    /// `ours` side's value (the agent / committed-snapshot side).
552    pub ours: ItemValue,
553    /// `theirs` side's value (the live editor / disk side).
554    pub theirs: ItemValue,
555}
556
557/// How a same-item-both-sides conflict is resolved deterministically. Recorded
558/// in the outcome regardless; the chosen value lands in `merged_text`.
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
560pub enum ConflictPolicy {
561    /// Keep `ours` (the agent / committed-snapshot side). Default — mirrors the
562    /// `exchange` committed-history spine in `reconcile_component_body`.
563    #[default]
564    OursWins,
565    /// Keep `theirs` (the live editor / disk side).
566    TheirsWins,
567}
568
569/// Per-component **authorship** conflict policy (#provauth1/#provauth4 — operator
570/// provenance authority). When the same keyed cell was edited on both sides, the
571/// *owner* of that component wins, deterministically and cleanly — no in-band
572/// conflict marker.
573///
574/// - **Agent-owned** components (`exchange`, `status`, `log`) → [`OursWins`]:
575///   the binary authors the response/status/log spine, so the committed (ours)
576///   side is authoritative there (mirrors the `exchange` history spine and the
577///   `#pcwcwarn` "agent owns exchange" reconcile rule).
578/// - **Operator-owned** components (`queue`, `backlog`, `review`, `done`,
579///   `icebox`, and any other) → [`TheirsWins`]: the operator edits these in the
580///   live editor, so their edit is authoritative and must never be reset or
581///   surfaced as a suspicious conflict ("all operator actions should never be
582///   reset or treated as suspicious"). This is what makes the operator the
583///   authority over their own cells instead of ours-wins + a `<<<<<<<` marker.
584///
585/// [`OursWins`]: ConflictPolicy::OursWins
586/// [`TheirsWins`]: ConflictPolicy::TheirsWins
587pub fn component_conflict_policy(component: &str) -> ConflictPolicy {
588    match component {
589        "exchange" | "status" | "log" => ConflictPolicy::OursWins,
590        _ => ConflictPolicy::TheirsWins,
591    }
592}
593
594/// Outcome of a 3-way per-cell merge.
595#[derive(Debug, Clone, PartialEq, Eq)]
596pub struct CellMergeOutcome {
597    /// The merged document text. When `fell_back` is true this is empty and the
598    /// caller must run the legacy whole-doc / `merge_by_component` path instead.
599    pub merged_text: String,
600    /// Real conflicts surfaced (never silently dropped). Empty for a clean merge.
601    pub conflicts: Vec<CellConflict>,
602    /// True when the per-cell merge declined (structural divergence, duplicate
603    /// keys, unsplittable framing). The caller falls back; `merged_text` is empty.
604    pub fell_back: bool,
605}
606
607impl CellMergeOutcome {
608    fn fallback() -> Self {
609        CellMergeOutcome {
610            merged_text: String::new(),
611            conflicts: Vec::new(),
612            fell_back: true,
613        }
614    }
615}
616
617/// One top-level node of a document: an interstitial text slot or a component
618/// occurrence (framing + projected keyed items).
619enum DocNode {
620    Interstitial(String),
621    Component {
622        framing: ComponentFraming,
623        occurrence: ComponentOccurrence,
624    },
625}
626
627/// A component occurrence's exact open/close marker slices, so a merged body can
628/// be reframed losslessly (`open + merged_body + close`).
629struct ComponentFraming {
630    open: String,
631    close: String,
632}
633
634/// Parse a document into ordered top-level [`DocNode`]s: interstitials around
635/// each top-level component, with each component projected into keyed items.
636/// `None` on a parse error (caller falls back).
637fn parse_doc_nodes(doc: &str) -> Option<Vec<DocNode>> {
638    let comps = component::parse(doc).ok()?;
639    // Top-level components only (mirror `crdt::segment_into_nodes`).
640    let mut top: Vec<&Component> = comps
641        .iter()
642        .filter(|c| {
643            !comps.iter().any(|o| {
644                !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
645            })
646        })
647        .collect();
648    top.sort_by_key(|c| c.open_start);
649
650    let mut occurrence_counts: std::collections::HashMap<String, usize> =
651        std::collections::HashMap::new();
652    let mut nodes = Vec::with_capacity(top.len() * 2 + 1);
653    let mut cursor = 0usize;
654    for c in top {
655        nodes.push(DocNode::Interstitial(doc[cursor..c.open_start].to_string()));
656        let occ_idx = occurrence_counts.entry(c.name.clone()).or_insert(0);
657        let occurrence = *occ_idx;
658        *occ_idx += 1;
659        let framing = ComponentFraming {
660            open: doc[c.open_start..c.open_end].to_string(),
661            close: doc[c.close_start..c.close_end].to_string(),
662        };
663        nodes.push(DocNode::Component {
664            framing,
665            occurrence: project_component(doc, c, occurrence),
666        });
667        cursor = c.close_end;
668    }
669    nodes.push(DocNode::Interstitial(doc[cursor..].to_string()));
670    Some(nodes)
671}
672
673/// Reconcile one component marker line operator-authoritatively (`#qmarkerauth`).
674///
675/// The marker (`<!-- agent:name attrs -->` / `<!-- /agent:name -->`) is
676/// operator-authored configuration (priority / preset / `go` / activation / custom
677/// attributes), so `theirs` (the live editor / disk side) wins over `ours` (the
678/// agent / snapshot side) whenever the operator changed it; an agent-side change is
679/// honored only when the operator left the marker at base. With no base, `theirs`
680/// (operator) is authoritative. The marker analogue of the field-wise frontmatter
681/// merge (`#fmreset`): an operator marker edit is never reverted by the merge path.
682fn reconcile_marker_operator_authoritative<'a>(
683    base: Option<&str>,
684    ours: &'a str,
685    theirs: &'a str,
686) -> &'a str {
687    if ours == theirs {
688        return ours;
689    }
690    match base {
691        Some(b) if theirs != b => theirs,
692        Some(_) => ours,
693        None => theirs,
694    }
695}
696
697/// The ordered component-name sequence of a node list (for structural pairing).
698fn component_name_sequence(nodes: &[DocNode]) -> Vec<&str> {
699    nodes
700        .iter()
701        .filter_map(|n| match n {
702            DocNode::Component { occurrence, .. } => Some(occurrence.component.as_str()),
703            DocNode::Interstitial(_) => None,
704        })
705        .collect()
706}
707
708/// True for an item-structured component occurrence whose keys are unique. The
709/// whole-body fallback occurrence (single `…:body` item) is treated as
710/// item-structured too — its single keyed item still composes cleanly.
711fn keys_unique(occ: &ComponentOccurrence) -> bool {
712    let mut seen = std::collections::HashSet::new();
713    occ.items
714        .iter()
715        .all(|(k, _)| seen.insert(node_key_identity(k).to_string()))
716}
717
718/// The result of composing one occurrence: the merged ordered `(identity,
719/// value)` item list plus any conflicts surfaced.
720type ComposedOccurrence = (Vec<(String, ItemValue)>, Vec<CellConflict>);
721
722/// Compose the base→ours and base→theirs keyed item lists for one component
723/// occurrence into a merged ordered `(identity, value)` list, recording any
724/// same-item-both-sides conflicts.
725///
726/// Ordering policy mirrors `reconcile_component_body`: list components
727/// (`queue`/`backlog`/`review`/`done`) use `theirs` (the live/operator side) as
728/// the order spine; `exchange` uses `ours` (append-only committed history) as
729/// the spine. Items present on only one side weave in after their nearest placed
730/// predecessor.
731fn compose_occurrence(
732    base: &ComponentOccurrence,
733    ours: &ComponentOccurrence,
734    theirs: &ComponentOccurrence,
735    policy: ConflictPolicy,
736) -> Option<ComposedOccurrence> {
737    // The two keyed op sets the cutover composes: base→ours and base→theirs,
738    // each via the lazily LIS keyed `reconcile`. From each we read which item
739    // identities that side *touched* (Insert/Update/Move) and which it *removed*.
740    // The per-key resolution below composes the two op streams: an identity
741    // touched on one side but not the other takes that side; an identity updated
742    // on BOTH sides to different text is a real conflict; an identity removed by
743    // the authoritative owner is dropped even if the other side updated it.
744    // Clean one-sided removes keep the legacy behavior otherwise.
745    let base_to_ours = reconcile(&base.keyed_for_diff(), &ours.keyed_for_diff());
746    let base_to_theirs = reconcile(&base.keyed_for_diff(), &theirs.keyed_for_diff());
747    let ours_removed = removed_keys(&base_to_ours);
748    let theirs_removed = removed_keys(&base_to_theirs);
749    let ours_updated = updated_keys(&base_to_ours);
750    let theirs_updated = updated_keys(&base_to_theirs);
751
752    // Reorder-stable identity → value, for each side.
753    let to_map = |occ: &ComponentOccurrence| -> std::collections::HashMap<String, ItemValue> {
754        occ.items
755            .iter()
756            .map(|(k, v)| (node_key_identity(k).to_string(), v.clone()))
757            .collect()
758    };
759    let base_map = to_map(base);
760    let ours_map = to_map(ours);
761    let theirs_map = to_map(theirs);
762
763    let identity_seq = |occ: &ComponentOccurrence| -> Vec<String> {
764        occ.items
765            .iter()
766            .map(|(k, _)| node_key_identity(k).to_string())
767            .collect()
768    };
769
770    let is_exchange = ours.component == "exchange";
771    // `#queuestatemachine2`/`#qheadresidue`: list components (queue/backlog/
772    // review/done) carry per-item strike lifecycle, so a matched-but-different
773    // item is first routed through the `Live < Struck` join (anti-resurrection),
774    // mirroring `crdt::reconcile_list_item_lifecycle`. A struck side beats a stale
775    // un-strike from the persisted base/agent side regardless of the ours/theirs
776    // content policy. `exchange` blocks are not strike-bearing and keep the
777    // content-policy path.
778    let lifecycle_governed = is_list_component(&ours.component);
779    // Order spine: theirs (live) for list components, ours (history) for exchange.
780    let (spine, weave) = if is_exchange {
781        (identity_seq(ours), identity_seq(theirs))
782    } else {
783        (identity_seq(theirs), identity_seq(ours))
784    };
785    let order = weave_order(&spine, &weave);
786
787    let mut merged: Vec<(String, ItemValue)> = Vec::with_capacity(order.len());
788    let mut conflicts = Vec::new();
789    for id in &order {
790        let in_b = base_map.contains_key(id);
791        let in_o = ours_map.get(id);
792        let in_t = theirs_map.get(id);
793        let resolved: Option<ItemValue> = match (in_o, in_t) {
794            (Some(o), Some(t)) => {
795                if o == t {
796                    Some(o.clone())
797                } else if lifecycle_governed {
798                    // Lifecycle-aware join (`Live < Struck`). Classify each side's
799                    // visible lifecycle and join: if exactly one side is at the
800                    // joined (higher-ranked) level, that side's text governs — a
801                    // struck side can never be resurrected by a stale un-strike,
802                    // and this is NOT a conflict (lawful lifecycle progression).
803                    let o_life = QueueItemLifecycle::classify(o);
804                    let t_life = QueueItemLifecycle::classify(t);
805                    let joined = o_life.join(t_life);
806                    match (o_life == joined, t_life == joined) {
807                        // Only ours is at the lawful level → ours' text governs.
808                        (true, false) => Some(o.clone()),
809                        // Only theirs is at the lawful level → theirs' text governs.
810                        (false, true) => Some(t.clone()),
811                        // Both at the same lifecycle level but text differs → fall
812                        // through to the content-conflict resolution below.
813                        _ => Some(resolve_content_divergence(
814                            id,
815                            o,
816                            t,
817                            in_b,
818                            base_map.get(id),
819                            &ours_updated,
820                            &theirs_updated,
821                            ours,
822                            policy,
823                            &mut conflicts,
824                        )),
825                    }
826                } else {
827                    // Non-lifecycle component (exchange): content policy only.
828                    Some(resolve_content_divergence(
829                        id,
830                        o,
831                        t,
832                        in_b,
833                        base_map.get(id),
834                        &ours_updated,
835                        &theirs_updated,
836                        ours,
837                        policy,
838                        &mut conflicts,
839                    ))
840                }
841            }
842            // Present only in ours: ours kept/edited it, or theirs removed it.
843            // For operator-owned components (`TheirsWins`), an operator delete
844            // is authoritative even if the agent side concurrently updated or
845            // struck the same item; otherwise the stale agent side resurrects a
846            // queue line the operator explicitly removed. `exchange` remains
847            // protected append-only history.
848            (Some(o), None) => {
849                let authoritative_theirs_delete =
850                    policy == ConflictPolicy::TheirsWins || !ours_updated.contains(id);
851                if theirs_removed.contains(id) && authoritative_theirs_delete && !is_exchange {
852                    None
853                } else {
854                    Some(o.clone())
855                }
856            }
857            // Present only in theirs: symmetric.
858            (None, Some(t)) => {
859                if ours_removed.contains(id) && !theirs_updated.contains(id) && !is_exchange {
860                    None
861                } else {
862                    Some(t.clone())
863                }
864            }
865            (None, None) => None,
866        };
867        if let Some(v) = resolved {
868            merged.push((id.clone(), v));
869        }
870    }
871    Some((merged, conflicts))
872}
873
874/// Resolve a same-item-both-sides text divergence that is NOT subsumed by a
875/// lifecycle join (either a non-lifecycle component, or both sides at the same
876/// lifecycle level). Records a genuine [`ConflictKind::Content`] conflict when
877/// both sides changed the item vs base, applying the deterministic
878/// [`ConflictPolicy`]. Returns the chosen value.
879#[allow(clippy::too_many_arguments)]
880fn resolve_content_divergence(
881    id: &str,
882    o: &ItemValue,
883    t: &ItemValue,
884    in_b: bool,
885    base_value: Option<&ItemValue>,
886    ours_updated: &std::collections::HashSet<String>,
887    theirs_updated: &std::collections::HashSet<String>,
888    occ: &ComponentOccurrence,
889    policy: ConflictPolicy,
890    conflicts: &mut Vec<CellConflict>,
891) -> ItemValue {
892    // Each side's change is read from its reconcile op set (Update/Insert).
893    let o_changed = ours_updated.contains(id) || !in_b;
894    let t_changed = theirs_updated.contains(id) || !in_b;
895    match (o_changed, t_changed) {
896        // Only ours changed → take ours.
897        (true, false) => o.clone(),
898        // Only theirs changed → take theirs.
899        (false, true) => t.clone(),
900        // Both changed to different text at the same lifecycle level. With the
901        // opcapture sub-gate ON (the default), first attempt a real op-level
902        // 3-way merge: if the two sides edited DISJOINT regions of the cell, that
903        // converges cleanly with both edits preserved and is NOT a conflict. Only
904        // a genuine same-region overlap falls through to policy. With opcapture
905        // explicitly OFF (the legacy behavior), every both-changed cell is a REAL
906        // content conflict resolved by the deterministic policy.
907        (true, true) => {
908            if cell_merge_opcapture_enabled()
909                && let Some(base) = base_value
910                && let Some(merged) = op_level_merge(base, o, t)
911            {
912                // Disjoint edits converged op-by-op — no information was lost, so
913                // no conflict is recorded.
914                return merged;
915            }
916            let chosen = match policy {
917                ConflictPolicy::OursWins => o.clone(),
918                ConflictPolicy::TheirsWins => t.clone(),
919            };
920            conflicts.push(CellConflict {
921                component: occ.component.clone(),
922                occurrence: occ.occurrence,
923                identity: id.to_string(),
924                kind: ConflictKind::Content,
925                chosen: chosen.clone(),
926                ours: o.clone(),
927                theirs: t.clone(),
928            });
929            chosen
930        }
931        // Neither side changed vs base but o != t — impossible (both equal base ⇒
932        // o == t). Defensive: take ours.
933        (false, false) => o.clone(),
934    }
935}
936
937/// One contiguous character-range replacement of `base[start..end)` (char
938/// indices, half-open) by `replacement`, derived from a common-prefix /
939/// common-suffix diff. A pure insertion has `start == end`; a pure deletion has
940/// an empty `replacement`. This is the minimal single-span edit that turns base
941/// into the side.
942#[derive(Debug, Clone, PartialEq, Eq)]
943struct CharSpanEdit {
944    /// First differing char index in base (inclusive).
945    start: usize,
946    /// One past the last replaced char index in base (exclusive).
947    end: usize,
948    /// The replacement characters inserted in `[start, end)`.
949    replacement: Vec<char>,
950}
951
952/// Compute the minimal single contiguous `base → side` character edit by peeling
953/// the shared prefix and suffix. Deterministic and allocation-light. Returns
954/// `None` when the strings are identical (no edit). The middle (between the
955/// common prefix and suffix) is treated as one replaced region — this is exactly
956/// the granularity the disjoint-region test needs, and matches how a localized
957/// human/agent edit shows up (one inserted/replaced run).
958fn char_span_edit(base: &[char], side: &[char]) -> Option<CharSpanEdit> {
959    if base == side {
960        return None;
961    }
962    // Common prefix length.
963    let mut start = 0usize;
964    let max_pre = base.len().min(side.len());
965    while start < max_pre && base[start] == side[start] {
966        start += 1;
967    }
968    // Common suffix length, not overlapping the already-matched prefix.
969    let mut suffix = 0usize;
970    let max_suf = (base.len() - start).min(side.len() - start);
971    while suffix < max_suf && base[base.len() - 1 - suffix] == side[side.len() - 1 - suffix] {
972        suffix += 1;
973    }
974    let end = base.len() - suffix;
975    let replacement = side[start..side.len() - suffix].to_vec();
976    Some(CharSpanEdit {
977        start,
978        end,
979        replacement,
980    })
981}
982
983/// Whether two base-relative replaced ranges overlap. Adjacent (touching but not
984/// crossing) ranges are treated as **overlapping** because two edits that meet at
985/// the same boundary are not information-theoretically independent — keep them on
986/// the conflict path. Empty ranges (pure insertions) at the *same* point also
987/// overlap (both sides inserted at one anchor); distinct insertion points do not.
988fn ranges_overlap(a: &CharSpanEdit, b: &CharSpanEdit) -> bool {
989    // Two pure insertions (empty range) collide only at the identical anchor.
990    if a.start == a.end && b.start == b.end {
991        return a.start == b.start;
992    }
993    // Otherwise overlap iff the half-open intervals intersect, treating a pure
994    // insertion as touching the char boundary it sits at (closed at that point).
995    let (a_lo, a_hi) = (a.start, a.end.max(a.start));
996    let (b_lo, b_hi) = (b.start, b.end.max(b.start));
997    a_lo < b_hi && b_lo < a_hi
998        // Insertion exactly inside the other side's replaced span boundary.
999        || (a.start == a.end && a.start > b_lo && a.start < b_hi)
1000        || (b.start == b.end && b.start > a_lo && b.start < a_hi)
1001}
1002
1003/// Apply one [`CharSpanEdit`] (a base-relative replace) to a forked [`TextCrdt`]
1004/// via visible-index `delete`/`insert`. The fork still holds the base characters
1005/// at base indices, so `start`/`end` are valid visible indices on it.
1006fn apply_span_edit(crdt: &mut TextCrdt, edit: &CharSpanEdit) {
1007    // Delete the replaced base chars (from the back so earlier indices stay valid).
1008    for idx in (edit.start..edit.end).rev() {
1009        crdt.delete(idx);
1010    }
1011    // Insert the replacement at the span start.
1012    for (i, ch) in edit.replacement.iter().enumerate() {
1013        crdt.insert(edit.start + i, *ch);
1014    }
1015}
1016
1017/// Op-level 3-way merge of one cell's text (`#qcellmerge1` opcapture).
1018///
1019/// Builds a base [`TextCrdt`], forks it to a deterministic "ours" peer (`1`) and
1020/// "theirs" peer (`2`), replays each side's single-span char edit onto its fork,
1021/// then merges. Returns `Some(merged_text)` ONLY when the two edits touch
1022/// **disjoint** regions of the base (so the merge is unambiguous and preserves
1023/// both edits); returns `None` for a true same-region overlap, leaving the caller
1024/// on the conflict + [`ConflictPolicy`] path. Fully deterministic: fixed peer ids
1025/// (ours-before-theirs), no clocks or randomness.
1026fn op_level_merge(base: &str, ours: &str, theirs: &str) -> Option<String> {
1027    let base_chars: Vec<char> = base.chars().collect();
1028    let ours_edit = char_span_edit(&base_chars, &ours.chars().collect::<Vec<_>>())?;
1029    let theirs_edit = char_span_edit(&base_chars, &theirs.chars().collect::<Vec<_>>())?;
1030
1031    // Overlapping edits are an irreducible same-region conflict (`#qnodemerge5`):
1032    // do NOT op-merge them — fall through to the policy path.
1033    if ranges_overlap(&ours_edit, &theirs_edit) {
1034        return None;
1035    }
1036
1037    // Deterministic peer ids: ours=1, theirs=2 (stable ordering, no time/random).
1038    let base_crdt = TextCrdt::from_str(1, base);
1039    let mut ours_fork = base_crdt.fork(1);
1040    let mut theirs_fork = base_crdt.fork(2);
1041    apply_span_edit(&mut ours_fork, &ours_edit);
1042    apply_span_edit(&mut theirs_fork, &theirs_edit);
1043    ours_fork.merge(&theirs_fork);
1044    Some(ours_fork.text())
1045}
1046
1047/// Identities the side `Remove`d relative to base.
1048fn removed_keys(ops: &[DiffOp<String, ItemValue>]) -> std::collections::HashSet<String> {
1049    ops.iter()
1050        .filter_map(|op| match op {
1051            DiffOp::Remove { key } => Some(key.clone()),
1052            _ => None,
1053        })
1054        .collect()
1055}
1056
1057/// Identities the side `Update`d (changed value) relative to base.
1058fn updated_keys(ops: &[DiffOp<String, ItemValue>]) -> std::collections::HashSet<String> {
1059    ops.iter()
1060        .filter_map(|op| match op {
1061            DiffOp::Update { key, .. } => Some(key.clone()),
1062            _ => None,
1063        })
1064        .collect()
1065}
1066
1067/// Weave `weave`-only keys into the `spine` order: keep the spine order, then
1068/// insert each weave-only key after its nearest preceding already-placed weave
1069/// key (mirrors `crdt::order_union`).
1070fn weave_order(spine: &[String], weave: &[String]) -> Vec<String> {
1071    let spine_set: std::collections::HashSet<&str> = spine.iter().map(|s| s.as_str()).collect();
1072    let mut result: Vec<String> = spine.to_vec();
1073    for (i, k) in weave.iter().enumerate() {
1074        if spine_set.contains(k.as_str()) || result.iter().any(|r| r == k) {
1075            continue;
1076        }
1077        let mut insert_pos = 0usize;
1078        for j in (0..i).rev() {
1079            if let Some(p) = result.iter().position(|r| r == &weave[j]) {
1080                insert_pos = p + 1;
1081                break;
1082            }
1083        }
1084        result.insert(insert_pos, k.clone());
1085    }
1086    result
1087}
1088
1089/// 3-way per-cell merge (`#qcellmerge1` cutover): the lazily-`reconcile`-based
1090/// counterpart of [`crate::crdt::merge`].
1091///
1092/// For each top-level component occurrence (keyed by `node_key_identity`), the
1093/// base→ours and base→theirs op sets ([`reconcile`]) are composed into one merged
1094/// item list:
1095/// - an item changed on only one side → that side's value/position,
1096/// - an item removed on one side and untouched on the other → removed,
1097/// - the **same item updated on BOTH sides to different text → a real conflict**
1098///   ([`CellConflict`]) resolved by [`ConflictPolicy`] (default ours-wins),
1099///   never a fabricated blend.
1100///
1101/// List components order by `theirs` (operator/live order wins); `exchange`
1102/// stays append-only (ordered by `ours`, base blocks protected from deletion).
1103/// Merged components are recombined in document order inside ours' framing /
1104/// interstitials.
1105///
1106/// On any **structural divergence** — unequal component-name sequences, a
1107/// duplicate key, or an unsplittable component — the outcome signals `fell_back`
1108/// and the caller runs the existing whole-doc / `merge_by_component` path. The
1109/// merge never emits malformed framing.
1110pub fn merge_3way(base_doc: &str, ours_doc: &str, theirs_doc: &str) -> CellMergeOutcome {
1111    if ours_doc == theirs_doc {
1112        return CellMergeOutcome {
1113            merged_text: ours_doc.to_string(),
1114            conflicts: Vec::new(),
1115            fell_back: false,
1116        };
1117    }
1118
1119    let ours_nodes = match parse_doc_nodes(ours_doc) {
1120        Some(n) => n,
1121        None => return CellMergeOutcome::fallback(),
1122    };
1123    let theirs_nodes = match parse_doc_nodes(theirs_doc) {
1124        Some(n) => n,
1125        None => return CellMergeOutcome::fallback(),
1126    };
1127    let base_nodes = parse_doc_nodes(base_doc).unwrap_or_default();
1128
1129    let ours_names = component_name_sequence(&ours_nodes);
1130    let theirs_names = component_name_sequence(&theirs_nodes);
1131    // Structural divergence in the component set/order → fall back.
1132    if ours_names != theirs_names {
1133        return CellMergeOutcome::fallback();
1134    }
1135    // Inline-mode doc (no components): per-cell merge has nothing to isolate.
1136    if ours_names.is_empty() {
1137        return CellMergeOutcome::fallback();
1138    }
1139
1140    // Index base occurrences + framings by (component, occurrence) for per-cell
1141    // base resolution. The framing map (`#qmarkerauth`) lets the component branch
1142    // reconcile the operator-owned marker line 3-way instead of always reframing
1143    // with ours' marker.
1144    let mut base_by_key: std::collections::HashMap<(String, usize), &ComponentOccurrence> =
1145        std::collections::HashMap::new();
1146    let mut base_framing_by_key: std::collections::HashMap<(String, usize), &ComponentFraming> =
1147        std::collections::HashMap::new();
1148    for n in &base_nodes {
1149        if let DocNode::Component {
1150            framing,
1151            occurrence,
1152        } = n
1153        {
1154            base_by_key.insert(
1155                (occurrence.component.clone(), occurrence.occurrence),
1156                occurrence,
1157            );
1158            base_framing_by_key.insert(
1159                (occurrence.component.clone(), occurrence.occurrence),
1160                framing,
1161            );
1162        }
1163    }
1164
1165    // Collect base interstitials positionally so the per-cell merge can do a
1166    // 3-way merge of the structural text AROUND components (not just whitespace —
1167    // it can carry operator scratch comments / late tail edits). Only valid when
1168    // base has the SAME top-level component-name sequence as ours/theirs; on any
1169    // structural divergence we leave it empty and fall back to ours-wins.
1170    let base_interstitials: Vec<&str> = if component_name_sequence(&base_nodes) == ours_names {
1171        base_nodes
1172            .iter()
1173            .filter_map(|n| match n {
1174                DocNode::Interstitial(s) => Some(s.as_str()),
1175                DocNode::Component { .. } => None,
1176            })
1177            .collect()
1178    } else {
1179        Vec::new()
1180    };
1181
1182    // Walk ours/theirs in lockstep (same component-name sequence ⇒ same shape).
1183    // Conflict policy is chosen per component by authorship (#provauth1/#provauth4)
1184    // inside the component branch, not a single global default.
1185    let mut out = String::new();
1186    let mut all_conflicts = Vec::new();
1187    let empty_occurrence = |o: &ComponentOccurrence| ComponentOccurrence {
1188        component: o.component.clone(),
1189        occurrence: o.occurrence,
1190        items: Vec::new(),
1191    };
1192
1193    let mut theirs_iter = theirs_nodes.iter();
1194    let mut interstitial_index = 0usize;
1195    for ours_node in &ours_nodes {
1196        let theirs_node = theirs_iter.next().expect("node sequences aligned");
1197        match (ours_node, theirs_node) {
1198            (DocNode::Interstitial(o), DocNode::Interstitial(t)) => {
1199                // Interstitial framing (whitespace + operator scratch text around
1200                // components). 3-way merge it so a one-sided addition (e.g. a
1201                // scratch comment or late tail edit on the live/disk side only) is
1202                // preserved instead of being clobbered by the other side. Base is
1203                // aligned positionally; absent base ⇒ legacy ours-wins.
1204                let base = base_interstitials.get(interstitial_index).copied();
1205                let chosen = match base {
1206                    Some(b) if *o == b => t.as_str(), // only theirs diverged
1207                    Some(b) if t == b => o.as_str(),  // only ours diverged
1208                    _ => o.as_str(),                  // both diverged / no base ⇒ ours-wins
1209                };
1210                out.push_str(chosen);
1211                interstitial_index += 1;
1212            }
1213            (
1214                DocNode::Component {
1215                    framing: o_framing,
1216                    occurrence: o_occ,
1217                },
1218                DocNode::Component {
1219                    framing: t_framing,
1220                    occurrence: t_occ,
1221                },
1222            ) => {
1223                // Splittability parity with the legacy `reconcile_component_body`
1224                // (`#qcellmerge1` finalize-path parity): keyed reconciliation is
1225                // only sound when ours AND theirs both project to real keyed
1226                // children. When one side splits into keyed children (e.g. an
1227                // `exchange` carrying a `### Re:` response on the agent/ours side)
1228                // but the other projects to the reserved single whole-body item
1229                // (no `### Re:` yet on the live/disk side), pairing a `body` item
1230                // against `preamble`/`### Re:` items is unsound: the body item is
1231                // present-only-on-one-side, so the weave keeps it verbatim ALONGSIDE
1232                // the keyed side's items — duplicating the prompt and retaining the
1233                // stale `<!-- agent:boundary:… -->` marker that ours replaced. A
1234                // whitespace-only body item contributes nothing and is harmless
1235                // (the keyed side wins cleanly — e.g. a fresh `### Re:` against an
1236                // empty exchange), so only a body item carrying real content forces
1237                // the fallback. The legacy `split(ours)? / split(theirs)?` guard
1238                // leaf-merges the whole component here; mirror that by falling back
1239                // so the caller runs the legacy whole-doc / per-node merge unchanged.
1240                if is_body_only(o_occ) != is_body_only(t_occ) {
1241                    let body_only_side = if is_body_only(o_occ) { o_occ } else { t_occ };
1242                    let body_only_has_content = body_only_side
1243                        .items
1244                        .iter()
1245                        .any(|(_, value)| !value.trim().is_empty());
1246                    if body_only_has_content {
1247                        return CellMergeOutcome::fallback();
1248                    }
1249                }
1250                let base_occ = base_by_key
1251                    .get(&(o_occ.component.clone(), o_occ.occurrence))
1252                    .copied();
1253                let base_holder;
1254                let base_ref = match base_occ {
1255                    Some(b) => b,
1256                    None => {
1257                        base_holder = empty_occurrence(o_occ);
1258                        &base_holder
1259                    }
1260                };
1261                // Duplicate keys ⇒ keyed reconciliation unsound ⇒ fall back whole-doc.
1262                if !keys_unique(o_occ) || !keys_unique(t_occ) || !keys_unique(base_ref) {
1263                    return CellMergeOutcome::fallback();
1264                }
1265                // #provauth1/#provauth4: resolve same-cell conflicts by component
1266                // AUTHORSHIP — operator-owned cells (queue/backlog/…) keep the
1267                // operator's (theirs) edit, agent-owned cells (exchange/status/log)
1268                // keep the binary's (ours). The owner wins cleanly; no in-band
1269                // `<<<<<<<` marker is injected (operator actions are never reset or
1270                // surfaced as suspicious), superseding the legacy #qcellconflict
1271                // ours-wins+marker behavior.
1272                let comp_policy = component_conflict_policy(&o_occ.component);
1273                let (mut merged_items, conflicts) =
1274                    match compose_occurrence(base_ref, o_occ, t_occ, comp_policy) {
1275                        Some(r) => r,
1276                        None => return CellMergeOutcome::fallback(),
1277                    };
1278                // In-band conflict markers are OFF by default (#provauth1/#provauth4):
1279                // the owner already won cleanly above, so surfacing a `<<<<<<<`
1280                // marker would re-expose an operator action as a suspicious
1281                // conflict. Retained behind the explicit opt-in env gate only.
1282                if cell_merge_conflict_markers_enabled() {
1283                    surface_conflict_markers(&mut merged_items, &conflicts);
1284                }
1285                all_conflicts.extend(conflicts);
1286                // Recombine the body losslessly from the merged item values, then
1287                // reframe with the operator-authoritative marker (`#qmarkerauth`).
1288                // Previously this always reframed with OURS' marker, so an operator
1289                // attribute edit on the `<!-- agent:queue … -->` marker was silently
1290                // deleted on the next merge. The marker is operator-owned config, so
1291                // an operator change (theirs ≠ base) wins; an agent change wins only
1292                // when the operator left the marker at base.
1293                let body: String = merged_items.iter().map(|(_, v)| v.as_str()).collect();
1294                let base_framing = base_framing_by_key
1295                    .get(&(o_occ.component.clone(), o_occ.occurrence))
1296                    .copied();
1297                let open = reconcile_marker_operator_authoritative(
1298                    base_framing.map(|f| f.open.as_str()),
1299                    &o_framing.open,
1300                    &t_framing.open,
1301                );
1302                let close = reconcile_marker_operator_authoritative(
1303                    base_framing.map(|f| f.close.as_str()),
1304                    &o_framing.close,
1305                    &t_framing.close,
1306                );
1307                out.push_str(open);
1308                out.push_str(&body);
1309                out.push_str(close);
1310            }
1311            // Shape mismatch despite equal name sequences (interstitial vs
1312            // component): structural — fall back.
1313            _ => return CellMergeOutcome::fallback(),
1314        }
1315    }
1316
1317    // Structural safety net: the recombined document must re-segment to the same
1318    // top-level component name sequence (never emit malformed framing).
1319    match parse_doc_nodes(&out) {
1320        Some(merged_nodes) if component_name_sequence(&merged_nodes) == ours_names => {
1321            log_conflicts(&all_conflicts);
1322            CellMergeOutcome {
1323                merged_text: out,
1324                conflicts: all_conflicts,
1325                fell_back: false,
1326            }
1327        }
1328        _ => CellMergeOutcome::fallback(),
1329    }
1330}
1331
1332/// Build the deterministic, framing-safe in-band conflict marker for one
1333/// [`CellConflict`] (`#qcellconflict`).
1334///
1335/// The marker is a single **HTML comment** (`<!-- … -->`) — git-style
1336/// `<<<<<<< / ======= / >>>>>>>` rails showing BOTH sides verbatim, with `ours`
1337/// (the active/structural text already present in the body) named first. Its
1338/// inner content begins with `cell-merge-conflict` (deliberately **not** an
1339/// `agent:` / `/agent:` prefix), so [`crate::component::parse`] treats it as an
1340/// ordinary comment and never as a component open/close marker. It is emitted as
1341/// a comment specifically so it can never:
1342/// - introduce a new top-level component (its inner text never starts with
1343///   `agent:` / `/agent:`, and the verbatim sides are guarded by
1344///   [`marker_is_framing_safe`]), so `component_name_sequence` is unchanged, and
1345/// - create a new keyed list item or `### Re:` block (a comment line is neither a
1346///   `- ` list line nor a `### Re:` heading), so item segmentation is unchanged.
1347///
1348/// The block always ends in `\n` and is appended *after* the conflicted node's
1349/// verbatim value (which itself already ends in `\n`), so the body stays
1350/// line-structured and round-trips. Fully deterministic: stable rail tokens,
1351/// fixed ours-before-theirs ordering, no clocks/randomness.
1352fn conflict_marker(conflict: &CellConflict) -> String {
1353    let policy_chose_ours = conflict.chosen == conflict.ours;
1354    let chosen_side = if policy_chose_ours { "ours" } else { "theirs" };
1355    // Trim a single trailing newline from each side so the verbatim text sits on
1356    // its own line(s) inside the rails without a spurious blank line; the rails
1357    // themselves provide the line structure.
1358    let ours = conflict.ours.strip_suffix('\n').unwrap_or(&conflict.ours);
1359    let theirs = conflict
1360        .theirs
1361        .strip_suffix('\n')
1362        .unwrap_or(&conflict.theirs);
1363    format!(
1364        "<!-- cell-merge-conflict component={} occurrence={} identity={} kind={:?} chosen={} (operator: resolve; ours kept active)\n\
1365         <<<<<<< ours\n{ours}\n=======\n{theirs}\n>>>>>>> theirs\n-->\n",
1366        conflict.component, conflict.occurrence, conflict.identity, conflict.kind, chosen_side,
1367    )
1368}
1369
1370/// Defensive guard: a conflict marker must not contain any agent component
1371/// framing marker (`<!-- agent:NAME -->` / `<!-- /agent:NAME -->`), which would
1372/// change the document's top-level component-name sequence. Verbatim side text is
1373/// operator/agent content, so in principle a side could itself contain such a
1374/// marker; if so we decline to surface that conflict in-band (it stays
1375/// stderr-only) rather than risk corrupting framing. Returns true when safe.
1376fn marker_is_framing_safe(conflict: &CellConflict) -> bool {
1377    // The rails/comment scaffolding is fixed and safe; only the verbatim side
1378    // text could carry framing. `component::parse` keys off `<!-- agent:` /
1379    // `<!-- /agent:` openers, so reject either side carrying one.
1380    let carries_framing =
1381        |s: &str| s.contains("<!-- agent:") || s.contains("<!-- /agent:") || s.contains("-->");
1382    !(carries_framing(&conflict.ours) || carries_framing(&conflict.theirs))
1383}
1384
1385/// Append an operator-visible in-band conflict marker after each conflicted
1386/// node's value in `merged_items` (`#qcellconflict`). The conflicted node keeps
1387/// its active value (ours under the default policy); the marker surfaces BOTH
1388/// versions verbatim so the operator can resolve. A conflict whose side text
1389/// would itself perturb framing ([`marker_is_framing_safe`]) is skipped (left
1390/// stderr-only). Mutates the merged item values in place; never reorders.
1391fn surface_conflict_markers(merged_items: &mut [(String, ItemValue)], conflicts: &[CellConflict]) {
1392    for conflict in conflicts {
1393        if !marker_is_framing_safe(conflict) {
1394            eprintln!(
1395                "cell_merge conflict identity={} not surfaced in-band (side text carries framing); stderr-only",
1396                conflict.identity,
1397            );
1398            continue;
1399        }
1400        // Find the conflicted node by identity and append the marker to its
1401        // active value. The node's value is the chosen (active) side, already in
1402        // the body; appending after it keeps it the structural text.
1403        if let Some((_, value)) = merged_items
1404            .iter_mut()
1405            .find(|(id, _)| id == &conflict.identity)
1406        {
1407            // Keep the value line-terminated, then append the marker block. If the
1408            // value somehow lacks a trailing newline (last item, no EOL), add one
1409            // so the comment starts on its own line.
1410            if !value.ends_with('\n') {
1411                value.push('\n');
1412            }
1413            value.push_str(&conflict_marker(conflict));
1414        }
1415    }
1416}
1417
1418/// Emit one structured stderr line per surfaced conflict (honest operator-visible
1419/// surface, beyond a bare count). Lifecycle joins are never in this list, so each
1420/// line is a genuine same-level content disagreement that the policy resolved.
1421fn log_conflicts(conflicts: &[CellConflict]) {
1422    for c in conflicts {
1423        eprintln!(
1424            "cell_merge conflict kind={:?} component={} occurrence={} identity={} policy_chose_ours={}",
1425            c.kind,
1426            c.component,
1427            c.occurrence,
1428            c.identity,
1429            c.chosen == c.ours,
1430        );
1431    }
1432}
1433
1434// ---------------------------------------------------------------------------
1435// Op-capture rung (`#qcellmerge1` op routing): route captured editor ops to the
1436// cell whose span contains them, replay per cell, then run the SAME per-cell
1437// 3-way join as `merge_3way` — true per-cell op isolation instead of a text-diff
1438// guess.
1439//
1440// Scope boundary (NOT built here): if the SAME cell is changed by BOTH ours and
1441// theirs (ours edited it AND theirs has routed ops for it), this rung does NOT
1442// character-level interleave — that is plan Phase 6 `#lztextcrdt`. The reused
1443// `merge_3way` join treats such a cell via the existing same-level conflict
1444// policy (ours-wins, recorded as a conflict; surfaced as an in-band conflict
1445// marker when conflict-surfacing is enabled). The win here is: ops in DIFFERENT
1446// cells are perfectly isolated and intention-preserved via real replay, and a
1447// single-sided same-cell op replays exactly — and because the join delegates to
1448// `merge_3way`, the disjoint-edit `op_level_merge` and conflict-surfacing
1449// additions on this branch are inherited automatically by the op-routed path.
1450// ---------------------------------------------------------------------------
1451
1452/// Route captured editor ops to the cell (item span) that contains each one,
1453/// translating each op's absolute offset to a **cell-local** offset
1454/// (`op.offset - span.start`).
1455///
1456/// Returns `None` (the conservative bail — caller falls back to the text-diff
1457/// path) if ANY op:
1458/// - lands in structural framing (a component open/close marker, frontmatter, or
1459///   inter-item / inter-component interstitial text), i.e. is covered by no item
1460///   span;
1461/// - crosses a cell boundary (for a `Delete`, the whole `[offset, offset+len)`
1462///   range must lie within ONE item span);
1463/// - cannot be unambiguously placed (out-of-bounds, or the document failed to
1464///   project into spans).
1465///
1466/// Insert ops use `[offset, offset]` (a point), so an insert exactly at a cell's
1467/// trailing boundary is attributed to that cell only when the point is `< end`;
1468/// an insert at the very end of an item (== next item's start) attributes to the
1469/// next item, matching the editor's left-gravity-free absolute offset.
1470pub fn route_ops_to_cells(
1471    doc: &str,
1472    ops: &[EditorOp],
1473) -> Option<std::collections::HashMap<NodeKey, Vec<EditorOp>>> {
1474    if ops.is_empty() {
1475        return Some(std::collections::HashMap::new());
1476    }
1477    let spans = project_document_spans(doc)?;
1478    let mut routed: std::collections::HashMap<NodeKey, Vec<EditorOp>> =
1479        std::collections::HashMap::new();
1480    for op in ops {
1481        let (node_key, span) = match op {
1482            EditorOp::Insert { offset, .. } => {
1483                // A point edit: the containing item is the one whose half-open
1484                // span includes `offset`. An offset at the document's end, or in
1485                // framing, is covered by no item span → bail.
1486                let found = spans
1487                    .iter()
1488                    .find(|s| s.span.contains_offset(*offset))
1489                    .or_else(|| {
1490                        // Allow an insert exactly at an item's exclusive end only
1491                        // when no later item starts there (i.e. it is the trailing
1492                        // edge of the last item body). This keeps an append at the
1493                        // end of a cell body inside that cell rather than bailing.
1494                        spans.iter().find(|s| {
1495                            s.span.end == *offset && !spans.iter().any(|o| o.span.start == *offset)
1496                        })
1497                    });
1498                match found {
1499                    Some(s) => (s.node_key.clone(), s.span),
1500                    None => return None,
1501                }
1502            }
1503            EditorOp::Delete { offset, len } => {
1504                let hi = offset.checked_add(*len)?;
1505                // The WHOLE delete range must lie within one item span.
1506                let found = spans.iter().find(|s| s.span.contains_range(*offset, hi));
1507                match found {
1508                    Some(s) => (s.node_key.clone(), s.span),
1509                    None => return None,
1510                }
1511            }
1512        };
1513        let local = translate_to_cell_local(op, span.start)?;
1514        routed.entry(node_key).or_default().push(local);
1515    }
1516    Some(routed)
1517}
1518
1519/// Translate an absolute-offset op into a cell-local op by subtracting the
1520/// containing span's start. `None` if the offset is below the span start
1521/// (should not happen once routed, but a conservative guard).
1522fn translate_to_cell_local(op: &EditorOp, span_start: usize) -> Option<EditorOp> {
1523    match op {
1524        EditorOp::Insert { offset, text } => Some(EditorOp::Insert {
1525            offset: offset.checked_sub(span_start)?,
1526            text: text.clone(),
1527        }),
1528        EditorOp::Delete { offset, len } => Some(EditorOp::Delete {
1529            offset: offset.checked_sub(span_start)?,
1530            len: *len,
1531        }),
1532    }
1533}
1534
1535/// Op-aware 3-way per-cell merge: build the `theirs` side per cell by **replaying
1536/// the real captured ops** routed to that cell (not a text-diff guess), then run
1537/// the SAME per-cell 3-way join as [`merge_3way`].
1538///
1539/// When routing succeeds, every cell that received ops is reconstructed via
1540/// `replay_editor_ops(base_cell_text, routed_ops)`; cells with no routed ops keep
1541/// their base text. The reconstructed bodies are reassembled into a synthetic
1542/// `theirs` document inside the base framing, and the merge delegates to
1543/// [`merge_3way`] (base, ours, theirs-from-ops) so the lifecycle `Live<Struck`
1544/// join, the disjoint-edit `op_level_merge`, and honest conflict recording (plus
1545/// conflict-surfacing when enabled) all apply unchanged — the real-op path
1546/// inherits every engine addition on this branch.
1547///
1548/// If routing returns `None` (an op crossed a boundary or hit framing), the
1549/// outcome has `fell_back = true` and the caller uses the text-diff / legacy
1550/// path. A reconstructed `theirs` whose per-cell replay fails (stale ops) also
1551/// falls back.
1552pub fn merge_3way_with_ops(
1553    base: &str,
1554    ours: &str,
1555    theirs: &str,
1556    theirs_ops: &[EditorOp],
1557) -> CellMergeOutcome {
1558    // Route the live (theirs) ops against the BASE document — the captured ops
1559    // are absolute offsets into the document the editor mutated FROM, which is
1560    // the merge base.
1561    let routed = match route_ops_to_cells(base, theirs_ops) {
1562        Some(r) => r,
1563        None => return CellMergeOutcome::fallback(),
1564    };
1565
1566    // Reconstruct theirs per cell from the base, replaying routed ops in place,
1567    // then reframe losslessly. We rebuild the full theirs text and delegate to
1568    // `merge_3way` so the per-cell join is the single shared implementation.
1569    let reconstructed_theirs = match reconstruct_theirs_from_ops(base, &routed) {
1570        Some(t) => t,
1571        None => return CellMergeOutcome::fallback(),
1572    };
1573
1574    // Safety gate (mirrors the crdt op-replay invariant): the per-cell replay
1575    // must reproduce the editor-observed `theirs` byte-for-byte. If it does not,
1576    // the ops were captured against a divergent base (missed event / stale) — do
1577    // not trust them; fall back so the result is never worse than the diff path.
1578    if reconstructed_theirs != theirs {
1579        eprintln!(
1580            "[cell_merge] op-routing replay != theirs (stale/misaligned ops) — falling back to text-diff path"
1581        );
1582        return CellMergeOutcome::fallback();
1583    }
1584
1585    merge_3way(base, ours, &reconstructed_theirs)
1586}
1587
1588/// Rebuild a full document from `base`, replacing each routed cell's body slice
1589/// with its op-replayed text. Cells with no routed ops keep base text. Returns
1590/// `None` if the base fails to project into spans or any cell's replay is
1591/// out-of-bounds (stale ops).
1592fn reconstruct_theirs_from_ops(
1593    base: &str,
1594    routed: &std::collections::HashMap<NodeKey, Vec<EditorOp>>,
1595) -> Option<String> {
1596    let spans = project_document_spans(base)?;
1597    // Rebuild the document by walking the base bytes and substituting each item
1598    // span's text with its op-replayed value. Item spans tile each component body
1599    // with no gaps; framing between/around them is copied verbatim from base.
1600    let mut out = String::new();
1601    let mut cursor = 0usize;
1602    for item in &spans {
1603        // Copy verbatim framing before this item (markers, frontmatter, gaps).
1604        out.push_str(&base[cursor..item.span.start]);
1605        let cell_text = match routed.get(&item.node_key) {
1606            Some(ops) => replay_editor_ops(&item.value, ops)?,
1607            None => item.value.clone(),
1608        };
1609        out.push_str(&cell_text);
1610        cursor = item.span.end;
1611    }
1612    out.push_str(&base[cursor..]);
1613    Some(out)
1614}
1615
1616/// A reactive, per-item representation of a document: a [`CellTree`] root whose
1617/// children are component-occurrence subtrees, each of whose children are item
1618/// value cells.
1619///
1620/// The tree id scheme: the root id is `""`, a component-occurrence node id is
1621/// `component:occurrence`, and an item leaf id is its reorder-stable node-key
1622/// identity (`component:occurrence:item-id`). Applying [`ComponentDiff`] ops via
1623/// [`CellDocTree::apply`] mutates only the touched item cells / order signals —
1624/// a single-item edit invalidates exactly that leaf's value cell and nothing
1625/// else (per-cell isolation).
1626pub struct CellDocTree {
1627    root: CellTree<String, String>,
1628    /// Per component-occurrence ordered item map (parallel to the tree's item
1629    /// children) so ops apply via the LIS-driven [`CellMap`] path.
1630    occurrences: std::collections::HashMap<(String, usize), CellMap<String, String>>,
1631}
1632
1633impl CellDocTree {
1634    /// Build a reactive tree from a document revision.
1635    pub fn from_document(ctx: &Context, doc: &str) -> Self {
1636        let root: CellTree<String, String> = CellTree::leaf(ctx, String::new(), String::new());
1637        let mut occurrences = std::collections::HashMap::new();
1638        for occ in project_document(doc) {
1639            let occ_id = format!("{}:{}", occ.component, occ.occurrence);
1640            let occ_node = root.insert_child(ctx, occ_id.clone(), String::new());
1641            let map: CellMap<String, String> = CellMap::new(ctx);
1642            for (key, value) in &occ.items {
1643                let identity = node_key_identity(key).to_string();
1644                occ_node.insert_child(ctx, identity.clone(), value.clone());
1645                map.entry(ctx, identity, value.clone());
1646            }
1647            occurrences.insert((occ.component.clone(), occ.occurrence), map);
1648        }
1649        Self { root, occurrences }
1650    }
1651
1652    /// Root tree handle (for wiring derived computeds).
1653    pub fn root(&self) -> &CellTree<String, String> {
1654        &self.root
1655    }
1656
1657    /// Reactively read an item's value by its reorder-stable identity, or `None`
1658    /// if absent. A reader is invalidated only when *that* item changes.
1659    pub fn item_value(
1660        &self,
1661        ctx: &Context,
1662        component: &str,
1663        occurrence: usize,
1664        identity: &str,
1665    ) -> Option<String> {
1666        self.occurrences
1667            .get(&(component.to_string(), occurrence))
1668            .and_then(|m| m.get(ctx, &identity.to_string()))
1669    }
1670
1671    /// Reactive ordered item identities for a component occurrence.
1672    pub fn item_ids(&self, ctx: &Context, component: &str, occurrence: usize) -> Vec<String> {
1673        self.occurrences
1674            .get(&(component.to_string(), occurrence))
1675            .map(|m| m.keys(ctx))
1676            .unwrap_or_default()
1677    }
1678
1679    /// Incrementally update this tree from `old_doc` to `new_doc`, using the
1680    /// document diff + apply path instead of rebuilding every occurrence.
1681    pub fn update_to(&mut self, ctx: &Context, old_doc: &str, new_doc: &str) {
1682        for diff in diff_document(old_doc, new_doc) {
1683            self.apply(ctx, &diff);
1684        }
1685
1686        let new_occurrences: std::collections::HashSet<(String, usize)> = project_document(new_doc)
1687            .into_iter()
1688            .map(|occ| (occ.component, occ.occurrence))
1689            .collect();
1690        let existing: Vec<(String, usize)> = self.occurrences.keys().cloned().collect();
1691        for (component, occurrence) in existing {
1692            if !new_occurrences.contains(&(component.clone(), occurrence)) {
1693                self.occurrences.remove(&(component.clone(), occurrence));
1694                self.root
1695                    .remove_child(ctx, &format!("{component}:{occurrence}"));
1696            }
1697        }
1698    }
1699
1700    /// A small agent-doc semantic query over the reactive tree: unresolved
1701    /// prompt counts per subtree, memoized by lazily's `SemTree`.
1702    pub fn unresolved_prompt_counts(&self, ctx: &Context) -> SemTree<String, usize> {
1703        SemTree::build(ctx, &self.root, |value: &String, kids: &[usize]| {
1704            unresolved_prompt_count(value) + kids.iter().sum::<usize>()
1705        })
1706    }
1707
1708    /// Apply a component diff's minimal ops to the corresponding occurrence's
1709    /// item cells, driving the reactive tree to the new shape with minimal
1710    /// invalidation (stable items untouched, moves atomic, only changed values
1711    /// rewritten). Keeps the [`CellTree`] children in sync with the [`CellMap`].
1712    pub fn apply(&mut self, ctx: &Context, diff: &ComponentDiff) {
1713        let occ_key = (diff.component.clone(), diff.occurrence);
1714        let occ_id = format!("{}:{}", diff.component, diff.occurrence);
1715        let occ_node = self
1716            .root
1717            .child(&occ_id)
1718            .unwrap_or_else(|| self.root.insert_child(ctx, occ_id.clone(), String::new()));
1719        let map = self
1720            .occurrences
1721            .entry(occ_key)
1722            .or_insert_with(|| CellMap::new(ctx));
1723        apply_to_map(ctx, map, &diff.ops);
1724        apply_to_tree(ctx, &occ_node, &diff.ops);
1725    }
1726}
1727
1728fn unresolved_prompt_count(value: &str) -> usize {
1729    value
1730        .lines()
1731        .filter(|line| line.contains("[#") && !line.contains("~~"))
1732        .count()
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737    use super::*;
1738
1739    const DOC: &str = "\
1740---
1741agent_doc_format: template
1742---
1743<!-- agent:queue -->
1744- do [#alpha] first task
1745- do [#beta] second task
1746- do [#gamma] third task
1747<!-- /agent:queue -->
1748<!-- agent:backlog -->
1749- [#one] backlog item one
1750- [#two] backlog item two
1751<!-- /agent:backlog -->
1752";
1753
1754    fn queue_diff(diffs: &[ComponentDiff]) -> &ComponentDiff {
1755        diffs
1756            .iter()
1757            .find(|d| d.component == "queue")
1758            .expect("queue diff present")
1759    }
1760
1761    #[test]
1762    fn project_keys_use_component_occurrence_item_index_scheme() {
1763        let occs = project_document(DOC);
1764        let queue = occs
1765            .iter()
1766            .find(|o| o.component == "queue")
1767            .expect("queue projected");
1768        assert_eq!(queue.occurrence, 0);
1769        let keys: Vec<&str> = queue.items.iter().map(|(k, _)| k.as_str()).collect();
1770        // component:occurrence:item-id:index — item-id is the durable `id:<id>`.
1771        assert_eq!(keys[0], "queue:0:id:alpha:0");
1772        assert_eq!(keys[1], "queue:0:id:beta:1");
1773        assert_eq!(keys[2], "queue:0:id:gamma:2");
1774    }
1775
1776    #[test]
1777    fn reorder_yields_moves_not_replace() {
1778        // Move gamma to the top: a pure reorder.
1779        let reordered = DOC.replace(
1780            "- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
1781            "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
1782        );
1783        let diffs = diff_document(DOC, &reordered);
1784        let q = queue_diff(&diffs);
1785        assert!(
1786            q.ops.iter().all(|op| matches!(op, DiffOp::Move { .. })),
1787            "pure reorder must be moves only, not remove+insert: {:?}",
1788            q.ops
1789        );
1790        assert!(!q.ops.is_empty(), "reorder must produce at least one move");
1791        assert!(q.ops.iter().all(|op| matches!(op, DiffOp::Move { .. })),);
1792    }
1793
1794    #[test]
1795    fn insert_one_item_yields_single_insert() {
1796        let added = DOC.replace(
1797            "- do [#gamma] third task\n",
1798            "- do [#gamma] third task\n- do [#delta] fourth task\n",
1799        );
1800        let diffs = diff_document(DOC, &added);
1801        let q = queue_diff(&diffs);
1802        let inserts: Vec<_> = q
1803            .ops
1804            .iter()
1805            .filter(|op| matches!(op, DiffOp::Insert { .. }))
1806            .collect();
1807        assert_eq!(inserts.len(), 1, "exactly one insert: {:?}", q.ops);
1808        assert!(matches!(
1809            inserts[0],
1810            DiffOp::Insert { key, .. } if key == "queue:0:id:delta"
1811        ));
1812        // No spurious removes/updates on the siblings.
1813        assert!(
1814            !q.ops
1815                .iter()
1816                .any(|op| matches!(op, DiffOp::Remove { .. } | DiffOp::Update { .. })),
1817            "insert must not touch siblings: {:?}",
1818            q.ops
1819        );
1820    }
1821
1822    #[test]
1823    fn remove_one_item_yields_single_remove() {
1824        let removed = DOC.replace("- do [#beta] second task\n", "");
1825        let diffs = diff_document(DOC, &removed);
1826        let q = queue_diff(&diffs);
1827        let removes: Vec<_> = q
1828            .ops
1829            .iter()
1830            .filter(|op| matches!(op, DiffOp::Remove { .. }))
1831            .collect();
1832        assert_eq!(removes.len(), 1, "exactly one remove: {:?}", q.ops);
1833        assert!(matches!(
1834            removes[0],
1835            DiffOp::Remove { key } if key == "queue:0:id:beta"
1836        ));
1837    }
1838
1839    #[test]
1840    fn operator_queue_delete_wins_over_agent_update() {
1841        let ours = DOC.replace(
1842            "- do [#beta] second task\n",
1843            "- ~~do [#beta] second task~~\n",
1844        );
1845        let theirs = DOC.replace("- do [#beta] second task\n", "");
1846
1847        let outcome = merge_3way(DOC, &ours, &theirs);
1848
1849        assert!(!outcome.fell_back, "cell merge should handle queue delete");
1850        assert!(
1851            outcome.conflicts.is_empty(),
1852            "authoritative operator delete is not a conflict: {:?}",
1853            outcome.conflicts
1854        );
1855        assert!(
1856            !outcome.merged_text.contains("[#beta]"),
1857            "operator-deleted queue item must not be resurrected:\n{}",
1858            outcome.merged_text
1859        );
1860        assert!(outcome.merged_text.contains("[#alpha]"));
1861        assert!(outcome.merged_text.contains("[#gamma]"));
1862    }
1863
1864    #[test]
1865    fn single_item_edit_yields_one_update_isolated() {
1866        // Edit only beta's text; its `#id` key is stable across the edit.
1867        let edited = DOC.replace(
1868            "- do [#beta] second task\n",
1869            "- do [#beta] second task EDITED\n",
1870        );
1871        let diffs = diff_document(DOC, &edited);
1872        let q = queue_diff(&diffs);
1873        assert_eq!(
1874            q.ops.len(),
1875            1,
1876            "exactly one op for a single-item edit: {:?}",
1877            q.ops
1878        );
1879        match &q.ops[0] {
1880            DiffOp::Update { key, value } => {
1881                assert_eq!(key, "queue:0:id:beta");
1882                assert!(value.contains("EDITED"));
1883            }
1884            other => panic!("expected a single Update, got {other:?}"),
1885        }
1886        // Per-cell isolation: alpha and gamma produced no ops at all.
1887        assert!(
1888            !q.ops.iter().any(|op| {
1889                let k = match op {
1890                    DiffOp::Update { key, .. }
1891                    | DiffOp::Move { key, .. }
1892                    | DiffOp::Remove { key } => key.as_str(),
1893                    DiffOp::Insert { key, .. } => key.as_str(),
1894                };
1895                k.contains("alpha") || k.contains("gamma")
1896            }),
1897            "sibling items must be untouched: {:?}",
1898            q.ops
1899        );
1900    }
1901
1902    #[test]
1903    fn cross_component_isolation() {
1904        // Edit only a queue item; backlog must produce no ops (no cross-splice).
1905        let edited = DOC.replace(
1906            "- do [#alpha] first task\n",
1907            "- do [#alpha] first task CHANGED\n",
1908        );
1909        let diffs = diff_document(DOC, &edited);
1910        assert!(
1911            diffs.iter().all(|d| d.component != "backlog"),
1912            "an edit in queue must produce no ops for backlog: {diffs:?}"
1913        );
1914        // And the queue diff is exactly one Update.
1915        let q = queue_diff(&diffs);
1916        assert_eq!(q.ops.len(), 1);
1917        assert!(matches!(&q.ops[0], DiffOp::Update { key, .. } if key == "queue:0:id:alpha"));
1918    }
1919
1920    #[test]
1921    fn reactive_tree_single_edit_spares_sibling_cell() {
1922        let ctx = Context::new();
1923        let mut tree = CellDocTree::from_document(&ctx, DOC);
1924
1925        // A reader of the STABLE sibling alpha's value.
1926        let alpha_view = {
1927            let tree_ids = tree.item_value(&ctx, "queue", 0, "queue:0:id:alpha");
1928            assert!(tree_ids.is_some());
1929            ctx.computed({
1930                let map = tree
1931                    .occurrences
1932                    .get(&("queue".to_string(), 0))
1933                    .unwrap()
1934                    .clone();
1935                move |ctx| {
1936                    map.get(ctx, &"queue:0:id:alpha".to_string())
1937                        .unwrap_or_default()
1938                }
1939            })
1940        };
1941        let _ = ctx.get(&alpha_view);
1942        assert!(ctx.is_set(&alpha_view));
1943
1944        // Edit only beta and apply its diff.
1945        let edited = DOC.replace(
1946            "- do [#beta] second task\n",
1947            "- do [#beta] second task EDITED\n",
1948        );
1949        let diffs = diff_document(DOC, &edited);
1950        for d in &diffs {
1951            tree.apply(&ctx, d);
1952        }
1953
1954        // beta's cell updated…
1955        assert!(
1956            tree.item_value(&ctx, "queue", 0, "queue:0:id:beta")
1957                .unwrap()
1958                .contains("EDITED")
1959        );
1960        // …and alpha's value reader stayed cached (per-cell isolation).
1961        assert!(
1962            ctx.is_set(&alpha_view),
1963            "sibling value cell must not be invalidated by a per-item edit"
1964        );
1965    }
1966
1967    #[test]
1968    fn reactive_tree_reorder_keeps_membership_cached() {
1969        let ctx = Context::new();
1970        let mut tree = CellDocTree::from_document(&ctx, DOC);
1971        let occ_node = tree.root().child(&"queue:0".to_string()).unwrap();
1972        let len_view = {
1973            let node = occ_node.clone();
1974            ctx.computed(move |ctx| node.len(ctx))
1975        };
1976        assert_eq!(ctx.get(&len_view), 3);
1977
1978        let reordered = DOC.replace(
1979            "- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
1980            "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
1981        );
1982        let diffs = diff_document(DOC, &reordered);
1983        for d in &diffs {
1984            tree.apply(&ctx, d);
1985        }
1986        // Order changed…
1987        assert_eq!(
1988            tree.item_ids(&ctx, "queue", 0),
1989            vec![
1990                "queue:0:id:gamma".to_string(),
1991                "queue:0:id:alpha".to_string(),
1992                "queue:0:id:beta".to_string(),
1993            ]
1994        );
1995        // …but membership count reader stayed cached (pure reorder).
1996        assert!(
1997            ctx.is_set(&len_view),
1998            "a pure reorder must not invalidate the membership-count reader"
1999        );
2000        assert_eq!(ctx.get(&len_view), 3);
2001    }
2002
2003    fn tree_snapshot(
2004        ctx: &Context,
2005        tree: &CellDocTree,
2006    ) -> Vec<(String, usize, Vec<(String, String)>)> {
2007        let mut occurrences: Vec<(String, usize)> = tree.occurrences.keys().cloned().collect();
2008        occurrences.sort();
2009        occurrences
2010            .into_iter()
2011            .map(|(component, occurrence)| {
2012                let map = tree
2013                    .occurrences
2014                    .get(&(component.clone(), occurrence))
2015                    .expect("occurrence map present");
2016                let map_items: Vec<(String, String)> = map
2017                    .keys(ctx)
2018                    .into_iter()
2019                    .map(|id| {
2020                        let value = map.get(ctx, &id).expect("mapped value present");
2021                        (id, value)
2022                    })
2023                    .collect();
2024                let occ_id = format!("{component}:{occurrence}");
2025                let occ_node = tree.root().child(&occ_id).expect("occurrence node present");
2026                assert_eq!(
2027                    occ_node.child_ids(ctx),
2028                    map_items
2029                        .iter()
2030                        .map(|(id, _)| id.clone())
2031                        .collect::<Vec<_>>(),
2032                    "CellTree child order must match CellMap order for {occ_id}"
2033                );
2034                for (id, value) in &map_items {
2035                    assert_eq!(
2036                        occ_node.child(id).expect("child present").get(ctx),
2037                        *value,
2038                        "CellTree child value must match CellMap value for {id}"
2039                    );
2040                }
2041                (component, occurrence, map_items)
2042            })
2043            .collect()
2044    }
2045
2046    fn assert_incremental_matches_rebuild(ctx: &Context, tree: &CellDocTree, doc: &str) {
2047        let rebuilt = CellDocTree::from_document(ctx, doc);
2048        assert_eq!(tree_snapshot(ctx, tree), tree_snapshot(ctx, &rebuilt));
2049    }
2050
2051    fn eager_unresolved_prompt_count(doc: &str) -> usize {
2052        project_document(doc)
2053            .into_iter()
2054            .flat_map(|occ| occ.items.into_iter().map(|(_, value)| value))
2055            .map(|value| unresolved_prompt_count(&value))
2056            .sum()
2057    }
2058
2059    #[test]
2060    fn update_to_matches_rebuild_across_revisions() {
2061        let ctx = Context::new();
2062        let mut tree = CellDocTree::from_document(&ctx, DOC);
2063        let mut old = DOC.to_string();
2064
2065        let rev1 = old.replace(
2066            "- do [#beta] second task\n",
2067            "- do [#beta] second task EDITED\n",
2068        );
2069        let rev2 = rev1.replace(
2070            "- do [#alpha] first task\n- do [#beta] second task EDITED\n- do [#gamma] third task\n",
2071            "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task EDITED\n",
2072        );
2073        let rev3 = format!(
2074            "{rev2}<!-- agent:review -->\n- do [#review] inspect\n<!-- /agent:review -->\n"
2075        );
2076        let rev4 = rev3.replace(
2077            "<!-- agent:backlog -->\n- [#one] backlog item one\n- [#two] backlog item two\n<!-- /agent:backlog -->\n",
2078            "",
2079        );
2080        let rev5 = rev4
2081            .replace(
2082                "- do [#alpha] first task\n",
2083                "- do [#alpha] first task CHANGED\n",
2084            )
2085            .replace(
2086                "- do [#review] inspect\n",
2087                "- do [#review] inspect EDITED\n",
2088            );
2089
2090        for new_doc in [rev1, rev2, rev3, rev4, rev5] {
2091            tree.update_to(&ctx, &old, &new_doc);
2092            assert_incremental_matches_rebuild(&ctx, &tree, &new_doc);
2093            old = new_doc;
2094        }
2095    }
2096
2097    #[test]
2098    fn sem_tree_unresolved_prompt_counts_are_subtree_scoped() {
2099        use std::cell::Cell as StdCell;
2100        use std::rc::Rc;
2101
2102        let ctx = Context::new();
2103        let mut tree = CellDocTree::from_document(&ctx, DOC);
2104        let counts = tree.unresolved_prompt_counts(&ctx);
2105        assert_eq!(counts.value(&ctx), eager_unresolved_prompt_count(DOC));
2106        assert_eq!(counts.node_value(&ctx, &"queue:0".to_string()), Some(3));
2107        assert_eq!(counts.node_value(&ctx, &"backlog:0".to_string()), Some(2));
2108
2109        let queue_alpha_calls = Rc::new(StdCell::new(0usize));
2110        let backlog_one_calls = Rc::new(StdCell::new(0usize));
2111        let probe = SemTree::build(&ctx, tree.root(), {
2112            let queue_alpha_calls = Rc::clone(&queue_alpha_calls);
2113            let backlog_one_calls = Rc::clone(&backlog_one_calls);
2114            move |value: &String, kids: &[usize]| {
2115                if value.contains("#alpha") {
2116                    queue_alpha_calls.set(queue_alpha_calls.get() + 1);
2117                }
2118                if value.contains("#one") {
2119                    backlog_one_calls.set(backlog_one_calls.get() + 1);
2120                }
2121                unresolved_prompt_count(value) + kids.iter().sum::<usize>()
2122            }
2123        });
2124
2125        assert_eq!(probe.value(&ctx), 5);
2126        let queue_alpha_baseline = queue_alpha_calls.get();
2127        let backlog_one_baseline = backlog_one_calls.get();
2128        assert_eq!(probe.value(&ctx), 5);
2129        assert_eq!(
2130            queue_alpha_calls.get(),
2131            queue_alpha_baseline,
2132            "memoized re-read must not recompute queue leaf"
2133        );
2134        assert_eq!(
2135            backlog_one_calls.get(),
2136            backlog_one_baseline,
2137            "memoized re-read must not recompute backlog leaf"
2138        );
2139
2140        let backlog_slot = probe.node(&"backlog:0".to_string()).unwrap();
2141        let edited = DOC.replace(
2142            "- do [#alpha] first task\n",
2143            "- ~~do [#alpha] first task~~\n",
2144        );
2145        tree.update_to(&ctx, DOC, &edited);
2146        assert!(
2147            ctx.is_set(&backlog_slot),
2148            "editing queue must not invalidate the backlog semantic subtree"
2149        );
2150        assert_eq!(probe.node_value(&ctx, &"queue:0".to_string()), Some(2));
2151        assert_eq!(probe.node_value(&ctx, &"backlog:0".to_string()), Some(2));
2152        assert_eq!(queue_alpha_calls.get(), queue_alpha_baseline + 1);
2153        assert_eq!(
2154            backlog_one_calls.get(),
2155            backlog_one_baseline,
2156            "backlog semantic leaf must stay cached after a queue edit"
2157        );
2158
2159        let updated_counts = tree.unresolved_prompt_counts(&ctx);
2160        assert_eq!(
2161            updated_counts.value(&ctx),
2162            eager_unresolved_prompt_count(&edited)
2163        );
2164    }
2165
2166    // ----- 3-way per-cell merge (`merge_3way`) -----------------------------
2167
2168    /// Serialize the env-var-mutating tests: `AGENT_DOC_CELL_MERGE` is
2169    /// process-global, so parallel tests that set/remove it would race. Reuses the
2170    /// crate-wide lock so `crdt` flag tests serialize against these too.
2171    use super::CELL_MERGE_ENV_LOCK as ENV_LOCK;
2172
2173    const BASE3: &str = "\
2174---
2175agent_doc_format: template
2176---
2177<!-- agent:queue -->
2178- do [#alpha] first task
2179- do [#beta] second task
2180- do [#gamma] third task
2181<!-- /agent:queue -->
2182<!-- agent:exchange -->
2183### Re: earlier
2184prior response.
2185<!-- /agent:exchange -->
2186";
2187
2188    #[test]
2189    fn concurrent_edits_to_different_items_both_land_no_conflict() {
2190        // ours edits alpha, theirs edits gamma — different items, same component.
2191        let ours = BASE3.replace(
2192            "- do [#alpha] first task\n",
2193            "- do [#alpha] first task OURS\n",
2194        );
2195        let theirs = BASE3.replace(
2196            "- do [#gamma] third task\n",
2197            "- do [#gamma] third task THEIRS\n",
2198        );
2199        let out = merge_3way(BASE3, &ours, &theirs);
2200        assert!(!out.fell_back, "should not fall back");
2201        assert!(out.conflicts.is_empty(), "no conflict: {:?}", out.conflicts);
2202        assert!(out.merged_text.contains("first task OURS"));
2203        assert!(out.merged_text.contains("third task THEIRS"));
2204        // beta untouched, no splice.
2205        assert!(out.merged_text.contains("- do [#beta] second task\n"));
2206    }
2207
2208    #[test]
2209    fn concurrent_edits_to_different_components_both_land() {
2210        // ours edits the queue, theirs edits the exchange — zero cross-component.
2211        let ours = BASE3.replace(
2212            "- do [#beta] second task\n",
2213            "- do [#beta] second task OURS\n",
2214        );
2215        let theirs = BASE3.replace("prior response.", "prior response. THEIRS");
2216        let out = merge_3way(BASE3, &ours, &theirs);
2217        assert!(!out.fell_back);
2218        assert!(out.conflicts.is_empty());
2219        assert!(out.merged_text.contains("second task OURS"));
2220        assert!(out.merged_text.contains("prior response. THEIRS"));
2221    }
2222
2223    #[test]
2224    fn exchange_response_does_not_splice_into_queue() {
2225        // The classic corruption repro: one side appends a `### Re:` response,
2226        // the other edits a queue item. The response must NOT land inside queue.
2227        let with_response = BASE3.replace(
2228            "### Re: earlier\nprior response.\n",
2229            "### Re: earlier\nprior response.\n### Re: new prompt\nbrand new agent response.\n",
2230        );
2231        let edited_queue = BASE3.replace(
2232            "- do [#beta] second task\n",
2233            "- do [#beta] second task EDITED\n",
2234        );
2235        let out = merge_3way(BASE3, &with_response, &edited_queue);
2236        assert!(!out.fell_back, "should per-cell merge");
2237        // Locate the queue component body and assert the response is not in it.
2238        let q_open = out.merged_text.find("<!-- agent:queue -->").unwrap();
2239        let q_close = out.merged_text.find("<!-- /agent:queue -->").unwrap();
2240        let queue_body = &out.merged_text[q_open..q_close];
2241        assert!(
2242            !queue_body.contains("### Re:"),
2243            "exchange response spliced into queue body: {queue_body}"
2244        );
2245        // Both edits landed in their own components.
2246        assert!(out.merged_text.contains("brand new agent response."));
2247        assert!(out.merged_text.contains("second task EDITED"));
2248    }
2249
2250    #[test]
2251    fn same_item_edited_both_sides_surfaces_conflict() {
2252        // Both sides edit beta to DIFFERENT text → a real conflict, not a blend.
2253        // queue is operator-owned (#provauth1/#provauth4), so the conflict
2254        // resolves THEIRS-wins (the operator's edit is authoritative) cleanly,
2255        // with the losing (ours) side not blended in-band.
2256        let _guard = ENV_LOCK.lock().unwrap();
2257        let ours = BASE3.replace(
2258            "- do [#beta] second task\n",
2259            "- do [#beta] second task OURS-VERSION\n",
2260        );
2261        let theirs = BASE3.replace(
2262            "- do [#beta] second task\n",
2263            "- do [#beta] second task THEIRS-VERSION\n",
2264        );
2265        let out = merge_3way(BASE3, &ours, &theirs);
2266        assert!(!out.fell_back);
2267        assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
2268        let c = &out.conflicts[0];
2269        assert_eq!(c.component, "queue");
2270        assert_eq!(c.identity, "queue:0:id:beta");
2271        assert!(c.ours.contains("OURS-VERSION"));
2272        assert!(c.theirs.contains("THEIRS-VERSION"));
2273        // queue is operator-owned ⇒ theirs-wins; the chosen value is NOT a blend.
2274        assert!(c.chosen.contains("THEIRS-VERSION"));
2275        assert!(out.merged_text.contains("THEIRS-VERSION"));
2276        assert!(
2277            !out.merged_text.contains("OURS-VERSION"),
2278            "no fabricated blend: the losing ours value must not also appear"
2279        );
2280    }
2281
2282    #[test]
2283    fn reorder_one_side_edit_other_side_compose() {
2284        // ours reorders (gamma to top), theirs edits alpha's text.
2285        let ours = BASE3.replace(
2286            "- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
2287            "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
2288        );
2289        let theirs = BASE3.replace(
2290            "- do [#alpha] first task\n",
2291            "- do [#alpha] first task ALPHA-EDIT\n",
2292        );
2293        let out = merge_3way(BASE3, &ours, &theirs);
2294        assert!(!out.fell_back);
2295        assert!(out.conflicts.is_empty(), "reorder+edit compose cleanly");
2296        // The edit landed…
2297        assert!(out.merged_text.contains("first task ALPHA-EDIT"));
2298        // …and the order spine is theirs (live side) for a list component:
2299        // theirs kept base order alpha,beta,gamma, so the merged queue follows it.
2300        let q_open = out.merged_text.find("<!-- agent:queue -->").unwrap();
2301        let q_close = out.merged_text.find("<!-- /agent:queue -->").unwrap();
2302        let body = &out.merged_text[q_open..q_close];
2303        let pa = body.find("ALPHA-EDIT").unwrap();
2304        let pg = body.find("[#gamma]").unwrap();
2305        assert!(
2306            pa < pg,
2307            "theirs (live) order is the spine: alpha before gamma"
2308        );
2309    }
2310
2311    #[test]
2312    fn structural_divergence_falls_back() {
2313        // theirs drops the exchange component entirely → unequal component set.
2314        let theirs = "\
2315---
2316agent_doc_format: template
2317---
2318<!-- agent:queue -->
2319- do [#alpha] first task
2320- do [#beta] second task
2321- do [#gamma] third task
2322<!-- /agent:queue -->
2323";
2324        let ours = BASE3.replace("- do [#alpha] first task\n", "- do [#alpha] first task X\n");
2325        let out = merge_3way(BASE3, &ours, theirs);
2326        assert!(out.fell_back, "structural divergence must fall back");
2327        assert!(out.merged_text.is_empty());
2328
2329        let _guard = ENV_LOCK.lock().unwrap();
2330        // And with the flag ON, the legacy crdt::merge path still returns a valid
2331        // doc for that input (the seam falls through transparently).
2332        // SAFETY: single-threaded test, restored immediately.
2333        unsafe {
2334            std::env::set_var(CELL_MERGE_ENV, "1");
2335        }
2336        let base_state = crate::crdt::CrdtDoc::from_text(BASE3).encode_state();
2337        let legacy = crate::crdt::merge_by_component(Some(&base_state), &ours, theirs).unwrap();
2338        unsafe {
2339            std::env::remove_var(CELL_MERGE_ENV);
2340        }
2341        assert!(legacy.contains("<!-- agent:queue -->"));
2342        assert!(legacy.contains("first task X"));
2343    }
2344
2345    /// `#qcellmerge1` finalize-path parity: when ours projects the `exchange`
2346    /// into keyed `### Re:` children (the agent placed a response) but theirs is
2347    /// still a single whole-body item carrying a live prompt + the stale boundary
2348    /// (no `### Re:` yet), the keyed compose would weave theirs' body in verbatim
2349    /// alongside ours' items — duplicating the prompt and keeping the stale
2350    /// `<!-- agent:boundary:base1234 -->` marker that ours replaced. The
2351    /// splittability-mismatch guard must fall back so the caller runs the legacy
2352    /// whole-component leaf merge (which drops the stale boundary).
2353    #[test]
2354    fn keyed_exchange_vs_body_only_with_content_falls_back() {
2355        let base = "<!-- agent:exchange -->\n\
2356❯ Please reply\n\
2357<!-- agent:boundary:base1234 -->\n\
2358<!-- /agent:exchange -->\n";
2359        // OURS: the agent's boundary-aware append replaced the boundary with a
2360        // `### Re:` response + a fresh boundary (keyed children).
2361        let ours = "<!-- agent:exchange -->\n\
2362❯ Please reply\n\
2363### Re: answer\n\nDone.\n\n\
2364<!-- agent:boundary:fresh5678 -->\n\
2365<!-- /agent:exchange -->\n";
2366        // THEIRS: the operator typed another line before the old boundary; still
2367        // no `### Re:`, so it projects to one whole-body item with real content.
2368        let theirs = "<!-- agent:exchange -->\n\
2369❯ Please reply\n\
2370while I was typing the next queue item\n\
2371<!-- agent:boundary:base1234 -->\n\
2372<!-- /agent:exchange -->\n";
2373
2374        let out = merge_3way(base, ours, theirs);
2375        assert!(
2376            out.fell_back,
2377            "keyed-vs-body-only-with-content exchange must fall back to the legacy leaf merge"
2378        );
2379        assert!(out.merged_text.is_empty());
2380    }
2381
2382    /// Counterpart: a keyed-vs-body-only mismatch where the body-only side is
2383    /// EMPTY (a fresh `### Re:` against an empty exchange) is harmless — the keyed
2384    /// side wins cleanly, so the per-cell path must NOT fall back.
2385    #[test]
2386    fn keyed_exchange_vs_empty_body_only_does_not_fall_back() {
2387        let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
2388        let ours =
2389            "<!-- agent:exchange -->\n### Re: answer\nResponse body.\n<!-- /agent:exchange -->\n";
2390        let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
2391
2392        let out = merge_3way(base, ours, theirs);
2393        assert!(
2394            !out.fell_back,
2395            "an empty body-only exchange must stay on the per-cell path"
2396        );
2397        assert!(out.merged_text.contains("Response body."));
2398    }
2399
2400    #[test]
2401    fn flag_off_byte_identical_to_legacy() {
2402        // For representative inputs the kill-switched crdt::merge_by_component
2403        // output is deterministic and exercises the legacy whole-doc path. Per-cell
2404        // merge is default-ON, so the legacy path is reached via the explicit
2405        // kill-switch.
2406        let ours = BASE3.replace(
2407            "- do [#alpha] first task\n",
2408            "- do [#alpha] first task OURS\n",
2409        );
2410        let theirs = BASE3.replace(
2411            "- do [#gamma] third task\n",
2412            "- do [#gamma] third task THEIRS\n",
2413        );
2414        let base_state = crate::crdt::CrdtDoc::from_text(BASE3).encode_state();
2415
2416        let _guard = ENV_LOCK.lock().unwrap();
2417        // Flag explicitly OFF via the kill-switch.
2418        unsafe {
2419            std::env::set_var(CELL_MERGE_ENV, "0");
2420        }
2421        let off = crate::crdt::merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2422
2423        // Re-run, still OFF — deterministic, identical.
2424        let off2 = crate::crdt::merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2425        assert_eq!(off, off2, "legacy path is deterministic with the flag off");
2426        assert!(!cell_merge_enabled(), "kill-switch must disable");
2427        // The legacy path produced a valid merged doc.
2428        assert!(off.contains("first task OURS"));
2429        assert!(off.contains("third task THEIRS"));
2430        // SAFETY: still holding the lock — restore the default (ON).
2431        unsafe {
2432            std::env::remove_var(CELL_MERGE_ENV);
2433        }
2434    }
2435
2436    #[test]
2437    fn cell_merge_enabled_default_on_with_kill_switch() {
2438        let _guard = ENV_LOCK.lock().unwrap();
2439        // Default-ON: absent var ⇒ enabled.
2440        unsafe {
2441            std::env::remove_var(CELL_MERGE_ENV);
2442        }
2443        assert!(cell_merge_enabled(), "absent ⇒ default ON");
2444        // Empty / truthy / any unrecognized value ⇒ still ON.
2445        for v in ["1", "true", "on", "yes", "TRUE", " On ", "", "maybe"] {
2446            unsafe {
2447                std::env::set_var(CELL_MERGE_ENV, v);
2448            }
2449            assert!(cell_merge_enabled(), "{v:?} should stay ON");
2450        }
2451        // Only an explicit falsy kill-switch turns it off.
2452        for v in ["0", "false", "off", "no", "FALSE", " Off "] {
2453            unsafe {
2454                std::env::set_var(CELL_MERGE_ENV, v);
2455            }
2456            assert!(!cell_merge_enabled(), "{v:?} should kill (OFF)");
2457        }
2458        unsafe {
2459            std::env::remove_var(CELL_MERGE_ENV);
2460        }
2461    }
2462
2463    #[test]
2464    fn cell_merge_opcapture_default_on_and_master_kill_switch_gates_it() {
2465        let _guard = ENV_LOCK.lock().unwrap();
2466        // Default-ON: both vars absent ⇒ opcapture enabled.
2467        unsafe {
2468            std::env::remove_var(CELL_MERGE_ENV);
2469            std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2470        }
2471        assert!(cell_merge_opcapture_enabled(), "absent ⇒ default ON");
2472        // Explicit opcapture kill-switch turns the sub-feature off.
2473        for v in ["0", "false", "off", "no"] {
2474            unsafe {
2475                std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, v);
2476            }
2477            assert!(!cell_merge_opcapture_enabled(), "{v:?} should kill");
2478        }
2479        // Master kill-switch forces the sub-feature OFF regardless of its value.
2480        unsafe {
2481            std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
2482            std::env::set_var(CELL_MERGE_ENV, "0");
2483        }
2484        assert!(
2485            !cell_merge_opcapture_enabled(),
2486            "master kill-switch must force the sub-feature OFF"
2487        );
2488        unsafe {
2489            std::env::remove_var(CELL_MERGE_ENV);
2490            std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2491        }
2492    }
2493
2494    #[test]
2495    fn unsplittable_component_diffs_as_whole_body_update() {
2496        // A status-like component with no list items → one whole-body item.
2497        let doc = "\
2498<!-- agent:status -->
2499working on it
2500<!-- /agent:status -->
2501";
2502        let edited = doc.replace("working on it", "done now");
2503        let diffs = diff_document(doc, &edited);
2504        let s = diffs
2505            .iter()
2506            .find(|d| d.component == "status")
2507            .expect("status diff");
2508        assert_eq!(s.ops.len(), 1);
2509        assert!(matches!(&s.ops[0], DiffOp::Update { key, .. } if key == "status:0:body"));
2510    }
2511
2512    // ----- anti-resurrection lifecycle join (`#queuestatemachine2`/`#qheadresidue`)
2513
2514    /// Extract a component body from a merged doc.
2515    fn component_body<'a>(merged: &'a str, name: &str) -> &'a str {
2516        let open = format!("<!-- agent:{name} -->");
2517        let close = format!("<!-- /agent:{name} -->");
2518        merged
2519            .split(&open)
2520            .nth(1)
2521            .and_then(|s| s.split(&close).next())
2522            .unwrap_or("")
2523    }
2524
2525    fn live_count(body: &str, needle: &str) -> usize {
2526        body.lines()
2527            .filter(|l| l.contains(needle) && !l.contains("~~"))
2528            .count()
2529    }
2530
2531    /// Mirror of `crdt::merge_by_component_struck_id_head_not_resurrected_by_stale_unstrike`.
2532    /// BASE struck, OURS (agent) un-strikes, THEIRS (disk) stays struck → merged
2533    /// stays struck. The lifecycle join (`Live < Struck`) overrides ours-wins.
2534    #[test]
2535    fn struck_id_head_not_resurrected_by_stale_unstrike_cell_path() {
2536        let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
2537<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
2538        // OURS un-strikes (stale) AND appends an exchange response.
2539        let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
2540<!-- agent:queue -->\n- do [#abcd] fix the thing\n<!-- /agent:queue -->\n";
2541        // THEIRS correctly struck.
2542        let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
2543<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
2544
2545        let out = merge_3way(base, ours, theirs);
2546        assert!(!out.fell_back, "should per-cell merge");
2547        let body = component_body(&out.merged_text, "queue");
2548        assert!(
2549            body.contains("~~do [#abcd] fix the thing~~"),
2550            "struck head must stay struck:\n{body}"
2551        );
2552        assert_eq!(
2553            live_count(body, "do [#abcd]"),
2554            0,
2555            "stale un-strike resurrected the struck head LIVE:\n{body}"
2556        );
2557        // A strike-vs-unstrike is a lawful lifecycle join, NOT a conflict.
2558        assert!(
2559            out.conflicts.is_empty(),
2560            "lifecycle join must not be a conflict: {:?}",
2561            out.conflicts
2562        );
2563        // The agent's exchange response still landed (no cross-cell loss).
2564        assert!(out.merged_text.contains("Agent response body."));
2565    }
2566
2567    /// Free-text variant: the struck head is an operator free-text line (no `#id`).
2568    #[test]
2569    fn struck_free_text_head_not_resurrected_by_stale_unstrike_cell_path() {
2570        let base = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
2571        let ours = "<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
2572        let theirs = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
2573
2574        let out = merge_3way(base, ours, theirs);
2575        assert!(!out.fell_back);
2576        let body = component_body(&out.merged_text, "queue");
2577        assert!(
2578            body.contains("~~Still getting JB File Cache Conflict dialogs.~~"),
2579            "struck free-text head must stay struck:\n{body}"
2580        );
2581        assert_eq!(
2582            live_count(body, "Still getting JB File Cache Conflict dialogs."),
2583            0,
2584            "stale un-strike resurrected the struck free-text head LIVE:\n{body}"
2585        );
2586        assert!(out.conflicts.is_empty());
2587    }
2588
2589    /// Multi-cycle no-churn proof: strike → persist struck as next base → re-merge
2590    /// with a stale un-strike side → stays struck across every cycle.
2591    #[test]
2592    fn struck_head_stable_across_cycles_no_churn_cell_path() {
2593        let mut base =
2594            "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n".to_string();
2595        for cycle in 0..3 {
2596            let ours = "<!-- agent:queue -->\n- do [#abcd] answered\n<!-- /agent:queue -->\n";
2597            let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n";
2598            let out = merge_3way(&base, ours, theirs);
2599            assert!(!out.fell_back, "cycle {cycle}: fell back");
2600            let body = component_body(&out.merged_text, "queue");
2601            assert_eq!(
2602                live_count(body, "do [#abcd]"),
2603                0,
2604                "cycle {cycle}: head resurrected LIVE (churn):\n{body}"
2605            );
2606            assert!(
2607                body.contains("~~do [#abcd] answered~~"),
2608                "cycle {cycle}: struck head lost:\n{body}"
2609            );
2610            assert_eq!(
2611                body.matches("do [#abcd] answered").count(),
2612                1,
2613                "cycle {cycle}: head duplicated:\n{body}"
2614            );
2615            assert!(out.conflicts.is_empty(), "cycle {cycle}: spurious conflict");
2616            // Persist the lawful (struck) state as the next cycle's base.
2617            base = out.merged_text;
2618        }
2619    }
2620
2621    /// Backlog component gets the same anti-resurrection treatment as queue.
2622    #[test]
2623    fn struck_backlog_head_not_resurrected_cell_path() {
2624        let base =
2625            "<!-- agent:backlog -->\n- ~~[#bk1] backlog answered~~\n<!-- /agent:backlog -->\n";
2626        let ours = "<!-- agent:backlog -->\n- [#bk1] backlog answered\n<!-- /agent:backlog -->\n";
2627        let theirs =
2628            "<!-- agent:backlog -->\n- ~~[#bk1] backlog answered~~\n<!-- /agent:backlog -->\n";
2629
2630        let out = merge_3way(base, ours, theirs);
2631        assert!(!out.fell_back);
2632        let body = component_body(&out.merged_text, "backlog");
2633        assert!(
2634            body.contains("~~[#bk1] backlog answered~~"),
2635            "struck backlog head must stay struck:\n{body}"
2636        );
2637        assert_eq!(
2638            live_count(body, "[#bk1]"),
2639            0,
2640            "backlog head resurrected:\n{body}"
2641        );
2642        assert!(out.conflicts.is_empty());
2643    }
2644
2645    /// Review component gets the same anti-resurrection treatment.
2646    #[test]
2647    fn struck_review_head_not_resurrected_cell_path() {
2648        let base = "<!-- agent:review -->\n- ~~[#rv1] review item~~\n<!-- /agent:review -->\n";
2649        let ours = "<!-- agent:review -->\n- [#rv1] review item\n<!-- /agent:review -->\n";
2650        let theirs = "<!-- agent:review -->\n- ~~[#rv1] review item~~\n<!-- /agent:review -->\n";
2651
2652        let out = merge_3way(base, ours, theirs);
2653        assert!(!out.fell_back);
2654        let body = component_body(&out.merged_text, "review");
2655        assert!(body.contains("~~[#rv1] review item~~"));
2656        assert_eq!(live_count(body, "[#rv1]"), 0);
2657        assert!(out.conflicts.is_empty());
2658    }
2659
2660    /// The symmetric case: OURS struck, THEIRS un-strikes (stale) → still struck.
2661    /// Anti-resurrection must hold regardless of which side carries the strike.
2662    #[test]
2663    fn struck_head_holds_when_theirs_is_the_stale_unstrike() {
2664        let base = "<!-- agent:queue -->\n- ~~do [#zz] q~~\n<!-- /agent:queue -->\n";
2665        let ours = "<!-- agent:queue -->\n- ~~do [#zz] q~~\n<!-- /agent:queue -->\n";
2666        let theirs = "<!-- agent:queue -->\n- do [#zz] q\n<!-- /agent:queue -->\n";
2667
2668        let out = merge_3way(base, ours, theirs);
2669        assert!(!out.fell_back);
2670        let body = component_body(&out.merged_text, "queue");
2671        assert!(
2672            body.contains("~~do [#zz] q~~"),
2673            "struck side must win:\n{body}"
2674        );
2675        assert_eq!(live_count(body, "do [#zz]"), 0);
2676        assert!(out.conflicts.is_empty());
2677    }
2678
2679    /// Strike-vs-unstrike is NOT recorded as a conflict (explicit honesty check).
2680    #[test]
2681    fn strike_vs_unstrike_is_not_a_conflict() {
2682        let base = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
2683        let ours = "<!-- agent:queue -->\n- do [#abcd] x\n<!-- /agent:queue -->\n";
2684        let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
2685        let out = merge_3way(base, ours, theirs);
2686        assert!(!out.fell_back);
2687        assert!(
2688            out.conflicts.is_empty(),
2689            "a lawful lifecycle join must never be a conflict: {:?}",
2690            out.conflicts
2691        );
2692    }
2693
2694    /// A genuine same-level content conflict (both Live, different text) IS
2695    /// recorded as exactly one `Content` conflict, ours-wins, theirs not present.
2696    #[test]
2697    fn genuine_same_level_content_conflict_is_recorded() {
2698        let _guard = ENV_LOCK.lock().unwrap();
2699        let base = "<!-- agent:queue -->\n- do [#beta] orig\n<!-- /agent:queue -->\n";
2700        let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
2701        let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
2702
2703        let out = merge_3way(base, ours, theirs);
2704        assert!(!out.fell_back);
2705        assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
2706        let c = &out.conflicts[0];
2707        assert_eq!(c.component, "queue");
2708        assert_eq!(c.identity, "queue:0:id:beta");
2709        assert_eq!(c.kind, ConflictKind::Content);
2710        assert!(c.ours.contains("OURS-VERSION"));
2711        assert!(c.theirs.contains("THEIRS-VERSION"));
2712        // queue is operator-owned ⇒ theirs-wins (#provauth1/#provauth4).
2713        assert!(
2714            c.chosen.contains("THEIRS-VERSION"),
2715            "operator-owned theirs-wins"
2716        );
2717        assert!(out.merged_text.contains("THEIRS-VERSION"));
2718        assert!(
2719            !out.merged_text.contains("OURS-VERSION"),
2720            "no fabricated blend: losing ours value must not also appear:\n{}",
2721            out.merged_text
2722        );
2723    }
2724
2725    /// Both sides struck the SAME head to DIFFERENT text (e.g. an operator note
2726    /// appended on one struck copy) → both at `Struck` level → a genuine content
2727    /// conflict, NOT silently dropped.
2728    #[test]
2729    fn both_sides_struck_different_text_is_a_content_conflict() {
2730        let _guard = ENV_LOCK.lock().unwrap();
2731        let base = "<!-- agent:queue -->\n- do [#k] live\n<!-- /agent:queue -->\n";
2732        let ours = "<!-- agent:queue -->\n- ~~do [#k] struck OURS~~\n<!-- /agent:queue -->\n";
2733        let theirs = "<!-- agent:queue -->\n- ~~do [#k] struck THEIRS~~\n<!-- /agent:queue -->\n";
2734
2735        let out = merge_3way(base, ours, theirs);
2736        assert!(!out.fell_back);
2737        assert_eq!(
2738            out.conflicts.len(),
2739            1,
2740            "both-struck-differ is a conflict: {:?}",
2741            out.conflicts
2742        );
2743        assert_eq!(out.conflicts[0].kind, ConflictKind::Content);
2744        // queue is operator-owned ⇒ theirs-wins (#provauth1/#provauth4).
2745        assert!(out.merged_text.contains("struck THEIRS"));
2746        assert!(!out.merged_text.contains("struck OURS"));
2747    }
2748
2749    // ----- op-level TextCrdt 3-way merge (`#qcellmerge1` opcapture) ----------
2750
2751    /// Unit-level proof of the disjoint-region detector + op-level merge,
2752    /// independent of env gating. Two edits to non-overlapping spans of one base
2753    /// string converge with BOTH preserved.
2754    #[test]
2755    fn op_level_merge_disjoint_edits_converge_both_present() {
2756        let base = "do [#beta] the original task body";
2757        // ours edits the FRONT ("the original" → "the EDITED original"); theirs
2758        // edits the BACK ("task body" → "task BODY!!"). Disjoint regions.
2759        let ours = "do [#beta] the EDITED original task body";
2760        let theirs = "do [#beta] the original task BODY!!";
2761        let merged = op_level_merge(base, ours, theirs).expect("disjoint edits merge");
2762        assert!(merged.contains("EDITED"), "ours edit present: {merged}");
2763        assert!(merged.contains("BODY!!"), "theirs edit present: {merged}");
2764    }
2765
2766    /// A true SAME-region overlap returns `None` from the op-level merger, so the
2767    /// caller stays on the conflict + policy path (the `#qnodemerge5` remainder).
2768    #[test]
2769    fn op_level_merge_same_region_overlap_is_none() {
2770        let base = "do [#beta] original";
2771        // Both sides replace the SAME trailing word with different text.
2772        let ours = "do [#beta] OURS";
2773        let theirs = "do [#beta] THEIRS";
2774        assert!(
2775            op_level_merge(base, ours, theirs).is_none(),
2776            "overlapping same-region edits must NOT op-merge"
2777        );
2778    }
2779
2780    /// Span-edit / overlap unit checks: prefix/suffix peel, pure insert, overlap.
2781    #[test]
2782    fn char_span_edit_and_overlap_detection() {
2783        let base: Vec<char> = "abcdef".chars().collect();
2784        // Replace the middle "cd" with "XY".
2785        let e = char_span_edit(&base, &"abXYef".chars().collect::<Vec<_>>()).unwrap();
2786        assert_eq!((e.start, e.end), (2, 4));
2787        assert_eq!(e.replacement, vec!['X', 'Y']);
2788        // Identical → no edit.
2789        assert!(char_span_edit(&base, &base).is_none());
2790        // Disjoint spans do not overlap.
2791        let front = char_span_edit(&base, &"aZcdef".chars().collect::<Vec<_>>()).unwrap();
2792        let back = char_span_edit(&base, &"abcdeZ".chars().collect::<Vec<_>>()).unwrap();
2793        assert!(!ranges_overlap(&front, &back), "front vs back disjoint");
2794        // Same-region edits overlap.
2795        let o = char_span_edit(&base, &"abXXef".chars().collect::<Vec<_>>()).unwrap();
2796        let t = char_span_edit(&base, &"abYYef".chars().collect::<Vec<_>>()).unwrap();
2797        assert!(ranges_overlap(&o, &t), "same middle region overlaps");
2798    }
2799
2800    #[test]
2801    fn merge_3way_preserves_operator_marker_attribute_qmarkerauth() {
2802        // #qmarkerauth: the operator adds attributes to the agent:queue marker
2803        // (`<!-- agent:queue priority go -->`) on the live/disk (theirs) side. The
2804        // agent (ours) side still has the base marker. The cell merge MUST keep the
2805        // operator's marker attributes — never reframe with ours' bare marker (the
2806        // "adding an attribute to agent:queue deletes the attribute" bug).
2807        let base = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2808        let ours = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2809        let theirs = "<!-- agent:queue priority go -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2810        let out = merge_3way(base, ours, theirs);
2811        assert!(!out.fell_back, "should not fall back: {out:?}");
2812        assert!(
2813            out.merged_text.contains("<!-- agent:queue priority go -->"),
2814            "operator marker attribute lost (reverted to ours' marker): {}",
2815            out.merged_text
2816        );
2817    }
2818
2819    #[test]
2820    fn merge_3way_keeps_agent_marker_when_operator_left_it_at_base_qmarkerauth() {
2821        // The inverse: only the AGENT (ours) changed the marker; the operator left
2822        // it at base. The agent change is honored (operator didn't touch it).
2823        let base = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2824        let ours = "<!-- agent:queue go -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2825        let theirs = "<!-- agent:queue -->\n- do [#beta] task EDITED\n<!-- /agent:queue -->\n";
2826        let out = merge_3way(base, ours, theirs);
2827        assert!(!out.fell_back, "should not fall back: {out:?}");
2828        assert!(
2829            out.merged_text.contains("<!-- agent:queue go -->"),
2830            "agent marker change lost: {}",
2831            out.merged_text
2832        );
2833        assert!(
2834            out.merged_text.contains("EDITED"),
2835            "operator body edit lost: {}",
2836            out.merged_text
2837        );
2838    }
2839
2840    /// (a) End-to-end with the opcapture gate ON: two DISJOINT-region edits to the
2841    /// SAME exchange item converge with BOTH present and NO conflict.
2842    #[test]
2843    fn opcapture_on_disjoint_same_cell_edits_both_land_no_conflict() {
2844        let _guard = ENV_LOCK.lock().unwrap();
2845        // A stable `#id`-keyed queue item: the identity survives a text edit on
2846        // EITHER side, so both edits land on the SAME item (the `(both changed)`
2847        // branch). ours edits the FRONT, theirs the BACK — disjoint regions.
2848        let base = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
2849        let ours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
2850        let theirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
2851
2852        // SAFETY: single-threaded under ENV_LOCK; restored before unlock.
2853        unsafe {
2854            std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
2855        }
2856        let out = merge_3way(base, ours, theirs);
2857        unsafe {
2858            std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2859        }
2860
2861        assert!(!out.fell_back, "should not fall back: {out:?}");
2862        assert!(
2863            out.conflicts.is_empty(),
2864            "disjoint op-merge records NO conflict: {:?}",
2865            out.conflicts
2866        );
2867        assert!(
2868            out.merged_text.contains("EDITED"),
2869            "ours edit: {}",
2870            out.merged_text
2871        );
2872        assert!(
2873            out.merged_text.contains("THERE!"),
2874            "theirs edit: {}",
2875            out.merged_text
2876        );
2877    }
2878
2879    /// (b) End-to-end with the gate ON but a TRUE same-region overlap: still a
2880    /// recorded `Content` conflict, ours-wins, theirs absent.
2881    #[test]
2882    fn opcapture_on_same_region_overlap_still_conflicts() {
2883        let _guard = ENV_LOCK.lock().unwrap();
2884        let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
2885        let ours = "<!-- agent:queue -->\n- do [#beta] OURS-ONLY\n<!-- /agent:queue -->\n";
2886        let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-ONLY\n<!-- /agent:queue -->\n";
2887
2888        // SAFETY: single-threaded under ENV_LOCK; restored before unlock. The
2889        // conflict-marker sub-gate is explicitly killed (it is default-ON) so
2890        // "theirs absent in-band" is deterministic.
2891        unsafe {
2892            std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
2893            std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "0");
2894        }
2895        let out = merge_3way(base, ours, theirs);
2896        unsafe {
2897            std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2898            std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
2899        }
2900
2901        assert!(!out.fell_back);
2902        assert_eq!(
2903            out.conflicts.len(),
2904            1,
2905            "overlap still conflicts even with opcapture ON: {:?}",
2906            out.conflicts
2907        );
2908        assert_eq!(out.conflicts[0].kind, ConflictKind::Content);
2909        // queue is operator-owned ⇒ theirs-wins (#provauth1/#provauth4).
2910        assert!(
2911            out.merged_text.contains("THEIRS-ONLY"),
2912            "operator-owned theirs-wins"
2913        );
2914        assert!(
2915            !out.merged_text.contains("OURS-ONLY"),
2916            "no blend on a real overlap: {}",
2917            out.merged_text
2918        );
2919    }
2920
2921    /// (c) Gate explicitly OFF reproduces the legacy ours-wins behavior unchanged:
2922    /// even DISJOINT same-cell edits record a conflict and drop theirs.
2923    #[test]
2924    fn opcapture_off_legacy_owner_wins_unchanged() {
2925        let _guard = ENV_LOCK.lock().unwrap();
2926        let base = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
2927        let ours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
2928        let theirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
2929
2930        // SAFETY: opcapture is default-ON, so explicitly engage its kill-switch to
2931        // exercise the policy-only path (no op-level merge).
2932        unsafe {
2933            std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "0");
2934        }
2935        assert!(!cell_merge_opcapture_enabled(), "opcapture kill-switch off");
2936
2937        let out = merge_3way(base, ours, theirs);
2938        assert!(!out.fell_back);
2939        assert_eq!(
2940            out.conflicts.len(),
2941            1,
2942            "OFF: disjoint same-cell edits are a conflict (no op-merge): {:?}",
2943            out.conflicts
2944        );
2945        // queue is operator-owned ⇒ theirs-wins (#provauth1/#provauth4); with
2946        // opcapture OFF the disjoint ours edit is dropped, not blended.
2947        assert!(
2948            out.merged_text.contains("THERE!"),
2949            "operator-owned theirs-wins"
2950        );
2951        assert!(
2952            !out.merged_text.contains("EDITED"),
2953            "OFF: losing ours disjoint edit is dropped (no op-merge): {}",
2954            out.merged_text
2955        );
2956        // SAFETY: still holding the lock — restore the default (ON).
2957        unsafe {
2958            std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2959        }
2960    }
2961
2962    // ----- conflict surfacing (`#qcellconflict`) ----------------------------
2963
2964    /// Run `merge_3way` with the `AGENT_DOC_CELL_MERGE_CONFLICT_MARKERS` sub-gate
2965    /// set to `value` for the duration, restoring it under the env lock.
2966    fn merge_with_markers(
2967        value: Option<&str>,
2968        base: &str,
2969        ours: &str,
2970        theirs: &str,
2971    ) -> CellMergeOutcome {
2972        let _guard = ENV_LOCK.lock().unwrap();
2973        // SAFETY: single-threaded under ENV_LOCK; restored before unlock.
2974        unsafe {
2975            match value {
2976                Some(v) => std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v),
2977                None => std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV),
2978            }
2979        }
2980        let out = merge_3way(base, ours, theirs);
2981        unsafe {
2982            std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
2983        }
2984        out
2985    }
2986
2987    /// (a) Gate ON: a true same-region same-cell overlap surfaces an
2988    /// operator-visible conflict containing BOTH `ours` and `theirs` verbatim, and
2989    /// the merged doc still round-trips (does NOT set `fell_back`).
2990    #[test]
2991    fn conflict_markers_on_surfaces_both_versions_and_round_trips() {
2992        let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
2993        let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
2994        let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
2995
2996        let out = merge_with_markers(Some("1"), base, ours, theirs);
2997        assert!(!out.fell_back, "marker must not break round-trip: {out:?}");
2998        assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
2999
3000        // BOTH versions appear verbatim in the operator-visible surface.
3001        assert!(
3002            out.merged_text.contains("OURS-VERSION"),
3003            "ours verbatim: {}",
3004            out.merged_text
3005        );
3006        assert!(
3007            out.merged_text.contains("THEIRS-VERSION"),
3008            "theirs surfaced for operator resolution: {}",
3009            out.merged_text
3010        );
3011        // The git-style conflict rails are present.
3012        assert!(
3013            out.merged_text.contains("<<<<<<< ours"),
3014            "{}",
3015            out.merged_text
3016        );
3017        assert!(out.merged_text.contains("======="), "{}", out.merged_text);
3018        assert!(
3019            out.merged_text.contains(">>>>>>> theirs"),
3020            "{}",
3021            out.merged_text
3022        );
3023        // The active/structural list line is theirs — queue is operator-owned, so
3024        // theirs-wins (#provauth1/#provauth4); the marker (opt-in ON) still shows
3025        // both verbatim, but the active line is the operator's.
3026        assert!(
3027            out.merged_text.contains("- do [#beta] THEIRS-VERSION\n"),
3028            "theirs stays the active list line: {}",
3029            out.merged_text
3030        );
3031        // The marker is an HTML comment (framing-safe), so re-parsing yields the
3032        // SAME single top-level queue component.
3033        let parsed = component::parse(&out.merged_text).expect("merged doc parses");
3034        let top: Vec<&str> = parsed.iter().map(|c| c.name.as_str()).collect();
3035        assert_eq!(top, vec!["queue"], "component sequence unchanged: {top:?}");
3036    }
3037
3038    /// (b) Gate ON: disjoint-region edits (opcapture clean) and single-side edits
3039    /// produce NO conflict marker — only a genuine same-region overlap does.
3040    #[test]
3041    fn conflict_markers_on_no_marker_for_nonconflicting_edits() {
3042        // Single-side edit: ours edits beta, theirs untouched.
3043        let base = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two\n- do [#c] three\n<!-- /agent:queue -->\n";
3044        let ours = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two EDITED\n- do [#c] three\n<!-- /agent:queue -->\n";
3045        let out = merge_with_markers(Some("1"), base, ours, base);
3046        assert!(!out.fell_back);
3047        assert!(out.conflicts.is_empty(), "single-side edit is no conflict");
3048        assert!(
3049            !out.merged_text.contains("<<<<<<<"),
3050            "no conflict marker for a single-side edit: {}",
3051            out.merged_text
3052        );
3053
3054        // Disjoint edits to DIFFERENT items: ours edits a, theirs edits c.
3055        let theirs = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two\n- do [#c] three EDITED-T\n<!-- /agent:queue -->\n";
3056        let ours2 = "<!-- agent:queue -->\n- do [#a] one EDITED-O\n- do [#b] two\n- do [#c] three\n<!-- /agent:queue -->\n";
3057        let out2 = merge_with_markers(Some("1"), base, &ours2, theirs);
3058        assert!(!out2.fell_back);
3059        assert!(
3060            out2.conflicts.is_empty(),
3061            "different-item edits never conflict"
3062        );
3063        assert!(
3064            !out2.merged_text.contains("<<<<<<<"),
3065            "no marker for disjoint different-item edits: {}",
3066            out2.merged_text
3067        );
3068        assert!(out2.merged_text.contains("one EDITED-O"));
3069        assert!(out2.merged_text.contains("three EDITED-T"));
3070
3071        // Disjoint same-cell edits with opcapture ON: clean op-merge, no conflict,
3072        // so the conflict-marker gate has nothing to surface.
3073        {
3074            let _g = ENV_LOCK.lock().unwrap();
3075            let dbase = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
3076            let dours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
3077            let dtheirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
3078            // SAFETY: single-threaded under ENV_LOCK; both restored before unlock.
3079            unsafe {
3080                std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
3081                std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
3082            }
3083            let out3 = merge_3way(dbase, dours, dtheirs);
3084            unsafe {
3085                std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
3086                std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3087            }
3088            assert!(!out3.fell_back);
3089            assert!(
3090                out3.conflicts.is_empty(),
3091                "opcapture-clean disjoint edits are no conflict: {:?}",
3092                out3.conflicts
3093            );
3094            assert!(
3095                !out3.merged_text.contains("<<<<<<<"),
3096                "no marker when opcapture cleanly merges: {}",
3097                out3.merged_text
3098            );
3099        }
3100    }
3101
3102    /// (c) Gate ON: a strike-vs-unstrike lifecycle join is a lawful join, NOT a
3103    /// conflict, so it produces NO conflict marker.
3104    #[test]
3105    fn conflict_markers_on_no_marker_for_lifecycle_join() {
3106        let base = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
3107        let ours = "<!-- agent:queue -->\n- do [#abcd] x\n<!-- /agent:queue -->\n";
3108        let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
3109
3110        let out = merge_with_markers(Some("1"), base, ours, theirs);
3111        assert!(!out.fell_back);
3112        assert!(
3113            out.conflicts.is_empty(),
3114            "lifecycle join is never a conflict: {:?}",
3115            out.conflicts
3116        );
3117        assert!(
3118            !out.merged_text.contains("<<<<<<<"),
3119            "no conflict marker for a lawful lifecycle join: {}",
3120            out.merged_text
3121        );
3122        // The struck side still wins (anti-resurrection), unchanged by this rung.
3123        let body = component_body(&out.merged_text, "queue");
3124        assert!(body.contains("~~do [#abcd] x~~"), "{body}");
3125    }
3126
3127    /// (d) Gate explicitly OFF (kill-switch): byte-identical to the legacy
3128    /// behavior — ours-wins, conflict only on stderr, NO in-band marker. The
3129    /// default (absent) is now ON and DOES surface the marker, proving the gate
3130    /// flipped to default-ON-with-kill-switch.
3131    #[test]
3132    fn conflict_markers_default_off_optin_surfaces() {
3133        let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
3134        let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
3135        let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
3136
3137        // queue is operator-owned ⇒ theirs-wins. Default (absent) is OFF
3138        // (#provauth1/#provauth4): clean theirs-wins, NO in-band marker.
3139        let absent = merge_with_markers(None, base, ours, theirs);
3140        assert!(absent.merged_text.contains("THEIRS-VERSION"));
3141        assert!(
3142            !absent.merged_text.contains("OURS-VERSION"),
3143            "default OFF: losing ours not surfaced: {}",
3144            absent.merged_text
3145        );
3146        assert!(
3147            !absent.merged_text.contains("<<<<<<<"),
3148            "default OFF: no conflict marker: {}",
3149            absent.merged_text
3150        );
3151        // The conflict is still RECORDED (stderr surface) regardless of gate.
3152        assert_eq!(absent.conflicts.len(), 1, "conflict still recorded");
3153
3154        // Explicit opt-in ("1") DOES surface both versions in-band for inspection.
3155        let on = merge_with_markers(Some("1"), base, ours, theirs);
3156        assert!(
3157            on.merged_text.contains("THEIRS-VERSION")
3158                && on.merged_text.contains("OURS-VERSION")
3159                && on.merged_text.contains("<<<<<<<"),
3160            "opt-in ON surfaces both versions in-band: {}",
3161            on.merged_text
3162        );
3163        // Opt-in ON must diverge from the default-OFF clean output.
3164        assert_ne!(
3165            absent.merged_text, on.merged_text,
3166            "opt-in must diverge from the default-OFF clean output"
3167        );
3168    }
3169
3170    /// Sub-gate default-OFF + explicit opt-in + master-gate parsing
3171    /// (#provauth1/#provauth4 flipped the marker default to OFF).
3172    #[test]
3173    fn conflict_markers_gate_default_off_with_optin() {
3174        let _guard = ENV_LOCK.lock().unwrap();
3175        // Master stays default-ON (absent) throughout the sub-gate checks.
3176        unsafe {
3177            std::env::remove_var(CELL_MERGE_ENV);
3178            std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3179        }
3180        assert!(
3181            !cell_merge_conflict_markers_enabled(),
3182            "absent ⇒ default OFF"
3183        );
3184        // Only an explicit truthy value opts the marker surfacing IN.
3185        for v in ["1", "true", "on", "yes", "TRUE", " On "] {
3186            unsafe {
3187                std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v);
3188            }
3189            assert!(
3190                cell_merge_conflict_markers_enabled(),
3191                "{v:?} should opt-in ON"
3192            );
3193        }
3194        // Falsy / empty / unrecognized ⇒ stays OFF (default).
3195        for v in ["0", "false", "off", "no", "FALSE", " Off ", "", "maybe"] {
3196            unsafe {
3197                std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v);
3198            }
3199            assert!(
3200                !cell_merge_conflict_markers_enabled(),
3201                "{v:?} should stay OFF"
3202            );
3203        }
3204        // Master kill-switch forces the sub-feature OFF regardless of its value.
3205        unsafe {
3206            std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
3207            std::env::set_var(CELL_MERGE_ENV, "0");
3208        }
3209        assert!(
3210            !cell_merge_conflict_markers_enabled(),
3211            "master kill-switch must force the sub-feature OFF"
3212        );
3213        unsafe {
3214            std::env::remove_var(CELL_MERGE_ENV);
3215            std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3216        }
3217    }
3218
3219    /// A conflict whose side text would itself perturb framing is NOT surfaced
3220    /// in-band (stays stderr-only), and the doc still round-trips.
3221    #[test]
3222    fn conflict_markers_on_skips_framing_unsafe_side_text() {
3223        // theirs' value carries a `-->` that would prematurely close the marker
3224        // comment, so the marker is declined for safety.
3225        let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
3226        let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
3227        let theirs =
3228            "<!-- agent:queue -->\n- do [#beta] THEIRS with --> inside\n<!-- /agent:queue -->\n";
3229
3230        let out = merge_with_markers(Some("1"), base, ours, theirs);
3231        assert!(!out.fell_back, "must still round-trip: {out:?}");
3232        assert_eq!(out.conflicts.len(), 1, "conflict still recorded");
3233        assert!(
3234            !out.merged_text.contains("<<<<<<<"),
3235            "framing-unsafe side must not be surfaced in-band: {}",
3236            out.merged_text
3237        );
3238        // queue is operator-owned ⇒ theirs-wins; the active line is theirs (the
3239        // `-->` is harmless as ordinary list text, only unsafe inside a marker).
3240        assert!(out.merged_text.contains("THEIRS with --> inside"));
3241    }
3242
3243    // -----------------------------------------------------------------------
3244    // Op-capture front-end (`#qcellmerge1` op routing): span projection,
3245    // op routing, op-replayed `theirs`, and composition with the engine.
3246    // -----------------------------------------------------------------------
3247
3248    /// A simple single-component base used by the op-routing tests. Each queue
3249    /// item sits on its own line; the item span includes the trailing newline
3250    /// (the keyed-child splitter is lossless line-by-line).
3251    const OPDOC: &str = "\
3252<!-- agent:queue -->
3253- do [#alpha] first task
3254- do [#beta] second task
3255- do [#gamma] third task
3256<!-- /agent:queue -->
3257";
3258
3259    /// `project_document_spans` tiles each component body losslessly: the item
3260    /// spans, concatenated in order, exactly reproduce the body text, and each
3261    /// span's slice equals its projected value.
3262    #[test]
3263    fn project_document_spans_tiles_body_losslessly() {
3264        let spans = project_document_spans(OPDOC).expect("spans project");
3265        assert_eq!(spans.len(), 3, "three queue items: {spans:?}");
3266        // Each span slices to its own value.
3267        for s in &spans {
3268            assert_eq!(&OPDOC[s.span.start..s.span.end], s.value.as_str());
3269        }
3270        // Spans are contiguous and tile the queue body with no gaps/overlap.
3271        for w in spans.windows(2) {
3272            assert_eq!(w[0].span.end, w[1].span.start, "spans must be contiguous");
3273        }
3274        // The concatenation reproduces the body between the open/close markers.
3275        let body_start = OPDOC.find("first task").unwrap() - "- do [#alpha] ".len();
3276        let body_end = OPDOC.find("<!-- /agent:queue -->").unwrap();
3277        let reassembled: String = spans.iter().map(|s| s.value.clone()).collect();
3278        assert_eq!(reassembled, &OPDOC[body_start..body_end]);
3279        // Identities are the durable reorder-stable keys.
3280        assert_eq!(spans[0].identity, "queue:0:id:alpha");
3281        assert_eq!(spans[2].identity, "queue:0:id:gamma");
3282    }
3283
3284    /// An op landing wholly inside one cell routes to that cell with a
3285    /// cell-local offset; a delete that crosses a cell boundary forces the
3286    /// conservative `None` bail (caller falls back).
3287    #[test]
3288    fn route_ops_to_cells_places_by_span_and_bails_on_boundary_cross() {
3289        let spans = project_document_spans(OPDOC).unwrap();
3290        let beta = spans
3291            .iter()
3292            .find(|s| s.identity == "queue:0:id:beta")
3293            .unwrap();
3294        // Insert " THEIRS" just before beta's trailing newline (inside beta).
3295        let abs = OPDOC.find("second task").unwrap() + "second task".len();
3296        assert!(beta.span.contains_offset(abs), "offset must be in beta");
3297        let ops = vec![EditorOp::Insert {
3298            offset: abs,
3299            text: " THEIRS".to_string(),
3300        }];
3301        let routed = route_ops_to_cells(OPDOC, &ops).expect("routes");
3302        assert_eq!(routed.len(), 1, "exactly one cell touched");
3303        let (key, cell_ops) = routed.iter().next().unwrap();
3304        assert_eq!(node_key_identity(key), "queue:0:id:beta");
3305        // The op's offset is translated to cell-local (offset - span.start).
3306        match &cell_ops[0] {
3307            EditorOp::Insert { offset, text } => {
3308                assert_eq!(*offset, abs - beta.span.start, "cell-local offset");
3309                assert_eq!(text, " THEIRS");
3310            }
3311            other => panic!("expected insert, got {other:?}"),
3312        }
3313
3314        // A delete spanning from inside beta into gamma crosses a boundary → bail.
3315        let gamma = spans
3316            .iter()
3317            .find(|s| s.identity == "queue:0:id:gamma")
3318            .unwrap();
3319        let cross_len = gamma.span.start + 1 - beta.span.start;
3320        let crossing = vec![EditorOp::Delete {
3321            offset: beta.span.start,
3322            len: cross_len,
3323        }];
3324        assert!(
3325            route_ops_to_cells(OPDOC, &crossing).is_none(),
3326            "boundary-crossing delete must bail to None"
3327        );
3328
3329        // An op in structural framing (the open marker) is covered by no item
3330        // span → bail.
3331        let framing = vec![EditorOp::Insert {
3332            offset: 1,
3333            text: "x".to_string(),
3334        }];
3335        assert!(
3336            route_ops_to_cells(OPDOC, &framing).is_none(),
3337            "framing op must bail to None"
3338        );
3339    }
3340
3341    /// The real-op path reconstructs `theirs` from captured ops and merges via
3342    /// `merge_3way`: a single-sided op in one cell lands, untouched cells stay.
3343    #[test]
3344    fn merge_3way_with_ops_replays_real_ops_and_merges() {
3345        // theirs edits beta via a real insert; ours is unchanged from base.
3346        let abs = OPDOC.find("second task").unwrap() + "second task".len();
3347        let ops = vec![EditorOp::Insert {
3348            offset: abs,
3349            text: " THEIRS".to_string(),
3350        }];
3351        let theirs = OPDOC.replace(
3352            "- do [#beta] second task\n",
3353            "- do [#beta] second task THEIRS\n",
3354        );
3355        let out = merge_3way_with_ops(OPDOC, OPDOC, &theirs, &ops);
3356        assert!(!out.fell_back, "real-op path should merge, not fall back");
3357        assert!(out.conflicts.is_empty(), "single-sided: no conflict");
3358        assert!(out.merged_text.contains("second task THEIRS"));
3359        // Untouched cells preserved.
3360        assert!(out.merged_text.contains("- do [#alpha] first task\n"));
3361        assert!(out.merged_text.contains("- do [#gamma] third task\n"));
3362    }
3363
3364    /// The byte-for-byte safety gate: if the routed-op replay does NOT reproduce
3365    /// the editor-observed `theirs` (stale / misaligned ops), the path declines
3366    /// (`fell_back`) instead of trusting the ops.
3367    #[test]
3368    fn merge_3way_with_ops_safety_gate_falls_back_on_stale_ops() {
3369        // Ops claim a " THEIRS" insert, but the supplied `theirs` text says
3370        // something different (DIVERGENT) — replay(base, ops) != theirs.
3371        let abs = OPDOC.find("second task").unwrap() + "second task".len();
3372        let ops = vec![EditorOp::Insert {
3373            offset: abs,
3374            text: " THEIRS".to_string(),
3375        }];
3376        let divergent_theirs = OPDOC.replace(
3377            "- do [#beta] second task\n",
3378            "- do [#beta] second task DIVERGENT\n",
3379        );
3380        let out = merge_3way_with_ops(OPDOC, OPDOC, &divergent_theirs, &ops);
3381        assert!(
3382            out.fell_back,
3383            "stale/misaligned ops must trip the byte-for-byte safety gate"
3384        );
3385    }
3386
3387    /// A boundary-crossing op makes routing return `None`, so the op-aware path
3388    /// declines and the caller uses the text-diff fallback.
3389    #[test]
3390    fn merge_3way_with_ops_falls_back_on_framing_op() {
3391        // An op in the open-marker framing routes to no cell → fall back.
3392        let ops = vec![EditorOp::Insert {
3393            offset: 1,
3394            text: "x".to_string(),
3395        }];
3396        // theirs text is irrelevant here (routing bails before the safety gate).
3397        let out = merge_3way_with_ops(OPDOC, OPDOC, OPDOC, &ops);
3398        assert!(out.fell_back, "framing op must fall back");
3399    }
3400
3401    /// COMPOSITION: the op-routed path delegates to `merge_3way`, so a same-cell
3402    /// concurrent edit (ours edits beta, theirs has a routed op for beta) still
3403    /// gets the engine's conflict-surfacing — the real-op front-end inherits the
3404    /// branch's engine additions.
3405    #[test]
3406    fn op_routed_same_cell_inherits_conflict_surfacing() {
3407        let _guard = ENV_LOCK.lock().unwrap();
3408        // Force the conflict-marker sub-gate ON deterministically.
3409        unsafe {
3410            std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
3411        }
3412        // ours edits beta one way; theirs' real op edits beta a DIFFERENT way to
3413        // the SAME region → a genuine same-cell conflict after delegation.
3414        let ours = OPDOC.replace(
3415            "- do [#beta] second task\n",
3416            "- do [#beta] second task OURS-VERSION\n",
3417        );
3418        let abs = OPDOC.find("second task").unwrap() + "second task".len();
3419        let ops = vec![EditorOp::Insert {
3420            offset: abs,
3421            text: " THEIRS-VERSION".to_string(),
3422        }];
3423        let theirs = OPDOC.replace(
3424            "- do [#beta] second task\n",
3425            "- do [#beta] second task THEIRS-VERSION\n",
3426        );
3427        let out = merge_3way_with_ops(OPDOC, &ours, &theirs, &ops);
3428        unsafe {
3429            std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3430        }
3431        assert!(!out.fell_back, "real-op path should merge: {out:?}");
3432        assert_eq!(
3433            out.conflicts.len(),
3434            1,
3435            "same-cell divergence surfaces a conflict via merge_3way: {:?}",
3436            out.conflicts
3437        );
3438        let c = &out.conflicts[0];
3439        assert_eq!(c.identity, "queue:0:id:beta");
3440        assert!(c.ours.contains("OURS-VERSION"));
3441        assert!(c.theirs.contains("THEIRS-VERSION"));
3442        // Conflict-surfacing (engine addition) reached the op-routed result:
3443        // both versions appear in-band via the surfaced marker block.
3444        assert!(
3445            out.merged_text.contains("OURS-VERSION") && out.merged_text.contains("THEIRS-VERSION"),
3446            "op-routed path must inherit in-band conflict-surfacing: {}",
3447            out.merged_text
3448        );
3449    }
3450
3451    /// COMPOSITION: the op-routed path also inherits the disjoint-edit
3452    /// `op_level_merge` — two real ops in DIFFERENT regions of the SAME cell
3453    /// converge with BOTH preserved and NO conflict.
3454    #[test]
3455    fn op_routed_disjoint_same_cell_edits_inherit_op_level_merge() {
3456        let _guard = ENV_LOCK.lock().unwrap();
3457        // Ensure the opcapture sub-gate is at its default-ON behavior.
3458        unsafe {
3459            std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
3460        }
3461        // ours appends to the END of beta's text; theirs' real op inserts at the
3462        // START of beta's content — disjoint regions of the same cell.
3463        let ours = OPDOC.replace(
3464            "- do [#beta] second task\n",
3465            "- do [#beta] second task OURS_TAIL\n",
3466        );
3467        // theirs op: insert "THEIRS_HEAD " right after "[#beta] ".
3468        let head_abs = OPDOC.find("second task").unwrap();
3469        let ops = vec![EditorOp::Insert {
3470            offset: head_abs,
3471            text: "THEIRS_HEAD ".to_string(),
3472        }];
3473        let theirs = OPDOC.replace(
3474            "- do [#beta] second task\n",
3475            "- do [#beta] THEIRS_HEAD second task\n",
3476        );
3477        let out = merge_3way_with_ops(OPDOC, &ours, &theirs, &ops);
3478        assert!(!out.fell_back, "real-op path should merge: {out:?}");
3479        assert!(
3480            out.conflicts.is_empty(),
3481            "disjoint same-cell edits must converge with no conflict: {:?}",
3482            out.conflicts
3483        );
3484        assert!(
3485            out.merged_text.contains("THEIRS_HEAD") && out.merged_text.contains("OURS_TAIL"),
3486            "both disjoint edits must survive (op_level_merge): {}",
3487            out.merged_text
3488        );
3489    }
3490
3491    /// When ops are ABSENT, the merge takes the existing text-diff path
3492    /// (`merge_3way`), proving the op front-end is purely additive — nothing is
3493    /// wasted in the degraded (no-capture) mode.
3494    #[test]
3495    fn ops_absent_uses_text_diff_path() {
3496        // ours edits alpha, theirs edits gamma — different cells, no ops.
3497        let ours = OPDOC.replace(
3498            "- do [#alpha] first task\n",
3499            "- do [#alpha] first task OURS\n",
3500        );
3501        let theirs = OPDOC.replace(
3502            "- do [#gamma] third task\n",
3503            "- do [#gamma] third task THEIRS\n",
3504        );
3505        // Direct text-diff engine call (the path merge_inner uses when ops==None).
3506        let out = merge_3way(OPDOC, &ours, &theirs);
3507        assert!(!out.fell_back);
3508        assert!(out.conflicts.is_empty());
3509        assert!(out.merged_text.contains("first task OURS"));
3510        assert!(out.merged_text.contains("third task THEIRS"));
3511    }
3512}