Skip to main content

agent_doc_core/
turn_scope.rs

1//! # Module: turn_scope
2//!
3//! TurnScope manifest — phase 2 of the operation-scoped drift model
4//! (`#op-scoped-drift-2`, `tasks/agent-doc/plan-operation-scoped-drift.md`).
5//!
6//! ## Spec
7//! - An `Address` names an addressable location in the document: a component
8//!   occurrence, optionally narrowed to a specific node key. A `node_key` of
9//!   `None` is a whole-component address.
10//! - A `TurnScope` is the operation manifest for the current turn: the `driver`
11//!   node it is answering, the `read_set` it depends on, and the `write_set` it
12//!   will mutate. It is emitted at turn start from preflight `prompt_targets`.
13//! - `TurnScope::for_driver` builds the canonical scope: read `{driver,
14//!   exchange tail}`; write `{exchange append, driver strike, backlog, status,
15//!   review, done/archive, gitlink, editor writeback}`. The driver also appears
16//!   in the write set because the turn strikes the queue item it consumes.
17//!
18//! ## Agentic Contracts
19//! - The manifest is the substrate the phase-3 affectedness classifier reads;
20//!   phase 2 only emits it. It never blocks a cycle.
21//! - When no driver node resolves (a non-queue prompt), `driver` is `None` and
22//!   the sets cover only the output components every turn touches.
23
24use serde::{Deserialize, Serialize};
25
26pub const COMPONENT_EXCHANGE: &str = "exchange";
27pub const COMPONENT_QUEUE: &str = "queue";
28pub const COMPONENT_BACKLOG: &str = "backlog";
29pub const COMPONENT_STATUS: &str = "status";
30pub const COMPONENT_REVIEW: &str = "review";
31pub const COMPONENT_DONE: &str = "done";
32pub const COMPONENT_DONE_ARCHIVE: &str = "done_archive";
33pub const COMPONENT_GITLINK: &str = "gitlink";
34pub const COMPONENT_EDITOR_WRITEBACK: &str = "editor_writeback";
35
36/// Managed output surfaces a turn may mutate.
37///
38/// Most entries are markdown `agent:*` components. `done_archive`, `gitlink`,
39/// and `editor_writeback` are runtime surfaces addressed by the same classifier
40/// so drift handling can use one component-scoped contract.
41pub const MANAGED_OUTPUT_COMPONENTS: &[&str] = &[
42    COMPONENT_EXCHANGE,
43    COMPONENT_BACKLOG,
44    COMPONENT_STATUS,
45    COMPONENT_REVIEW,
46    COMPONENT_DONE,
47    COMPONENT_DONE_ARCHIVE,
48    COMPONENT_GITLINK,
49    COMPONENT_EDITOR_WRITEBACK,
50];
51
52/// All managed surfaces that can participate in conflict classification.
53///
54/// Queue is node-scoped through the driver address: the running item conflicts,
55/// while sibling queue inserts remain independent.
56pub const MANAGED_CONFLICT_COMPONENTS: &[&str] = &[
57    COMPONENT_EXCHANGE,
58    COMPONENT_QUEUE,
59    COMPONENT_BACKLOG,
60    COMPONENT_STATUS,
61    COMPONENT_REVIEW,
62    COMPONENT_DONE,
63    COMPONENT_DONE_ARCHIVE,
64    COMPONENT_GITLINK,
65    COMPONENT_EDITOR_WRITEBACK,
66];
67
68/// An addressable location in the document.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Address {
71    /// Component name (`queue`, `exchange`, `backlog`, …).
72    pub component: String,
73    /// Zero-based occurrence of the component in the document.
74    pub occurrence: usize,
75    /// Stable node key when the address is narrowed to a single node; `None`
76    /// addresses the whole component occurrence.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub node_key: Option<String>,
79}
80
81impl Address {
82    /// A whole-component address.
83    pub fn component(name: &str, occurrence: usize) -> Self {
84        Address {
85            component: name.to_string(),
86            occurrence,
87            node_key: None,
88        }
89    }
90
91    /// A node-level address inside a component.
92    pub fn node(component: &str, occurrence: usize, node_key: &str) -> Self {
93        Address {
94            component: component.to_string(),
95            occurrence,
96            node_key: Some(node_key.to_string()),
97        }
98    }
99
100    /// Build a node address from a component name and a stable node key,
101    /// parsing the component occurrence out of the `comp:occurrence:id:n` node
102    /// key. This is the single source of occurrence parsing shared by
103    /// [`op_address`] and the finalize-path drift gate, so an op's address and
104    /// the scope's driver address agree for the same node key.
105    pub fn from_component_node_key(component: &str, node_key: &str) -> Self {
106        let occurrence = node_key
107            .split(':')
108            .nth(1)
109            .and_then(|field| field.parse().ok())
110            .unwrap_or(0);
111        Address::node(component, occurrence, node_key)
112    }
113
114    /// True when this address targets a whole component rather than one node.
115    pub fn is_component_level(&self) -> bool {
116        self.node_key.is_none()
117    }
118
119    /// True when two addresses refer to overlapping document regions: the same
120    /// component, and either side is component-level (whole component) or both
121    /// name the same node key. This is the `overlaps` primitive behind the
122    /// affectedness `conflict` relation (`#op-scoped-drift-3`).
123    ///
124    /// Overlap is keyed on the component name plus the node key. The `occurrence`
125    /// field is informational and is intentionally *not* part of the match: a
126    /// node key already encodes the component index (`comp:index:id:n`), and the
127    /// canonical write-set members built by [`TurnScope::for_driver`] are
128    /// whole-component addresses (`occurrence: 0`) that must match the component
129    /// wherever it sits in the document (`#nm1x`).
130    pub fn overlaps(&self, other: &Address) -> bool {
131        if self.component != other.component {
132            return false;
133        }
134        match (&self.node_key, &other.node_key) {
135            (Some(a), Some(b)) => a == b,
136            // A component-level address overlaps any node in that component.
137            _ => true,
138        }
139    }
140}
141
142/// The operation manifest for the current turn.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
144pub struct TurnScope {
145    /// The driver node the turn is answering, when one resolves.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub driver: Option<Address>,
148    /// Addresses the turn depends on (input).
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub read_set: Vec<Address>,
151    /// Addresses the turn will mutate (output).
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub write_set: Vec<Address>,
154    /// Exchange node-index floor for the turn's active tail
155    /// (`#loop-guard-exchange-node-granularity`). The `exchange` component is
156    /// whole-component in `read_set`/`write_set`, but only its *tail* — nodes at
157    /// or after this index (the active turn's appends and edits) — affects the
158    /// turn. An edit to an OLD exchange block (index below the floor) is
159    /// `Independent` and must not preempt the auto-loop drain. `None` keeps the
160    /// coarse whole-component behavior (no exchange nodes / unknown tail).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub exchange_tail_floor: Option<usize>,
163}
164
165impl TurnScope {
166    /// Build the canonical turn scope for an optional driver queue node, with no
167    /// exchange-tail narrowing (whole-component exchange affectedness).
168    pub fn for_driver(driver: Option<Address>) -> Self {
169        Self::for_driver_with_exchange_tail(driver, None)
170    }
171
172    /// Build the canonical turn scope for an optional driver queue node, narrowing
173    /// the `exchange` component to its active tail at `exchange_tail_floor`.
174    ///
175    /// read_set = `{driver, exchange tail}`; write_set = the managed output
176    /// components plus the driver strike. Addresses are deduped while preserving
177    /// first-seen order so the manifest is stable. `exchange_tail_floor` is the
178    /// count of exchange nodes present at turn start: a later op at an index at
179    /// or above it is a tail append/edit (affects the turn); an op below it edits
180    /// committed history and is independent.
181    pub fn for_driver_with_exchange_tail(
182        driver: Option<Address>,
183        exchange_tail_floor: Option<usize>,
184    ) -> Self {
185        let exchange = Address::component(COMPONENT_EXCHANGE, 0);
186        let mut read_set = Vec::new();
187        if let Some(driver) = &driver {
188            read_set.push(driver.clone());
189        }
190        read_set.push(exchange.clone());
191
192        let mut write_set = managed_output_write_set();
193        if let Some(driver) = &driver {
194            // The turn strikes the queue item it consumes.
195            write_set.push(driver.clone());
196        }
197
198        TurnScope {
199            driver,
200            read_set: dedupe_addresses(read_set),
201            write_set: dedupe_addresses(write_set),
202            exchange_tail_floor,
203        }
204    }
205}
206
207/// The effect class an incoming operation has on the current turn
208/// (`#op-scoped-drift-3`). Mirrors the 5-class taxonomy in the plan.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum AffectednessClass {
212    /// Class 1 — target ∉ scope: integrate + persist, no drift.
213    Independent,
214    /// Class 2 — target ∈ read_set: re-read the affected input.
215    InputAffecting,
216    /// Class 3 — target ∈ write_set with a concurrent writer: CRDT merge.
217    OutputContended,
218    /// Class 4 — remove/move of a depended node: invalidate/adapt.
219    StructuralDependency,
220    /// Class 5 — non-user actor (lagging live-buffer) misread as a user edit: suppress.
221    ProvenanceSpoofed,
222}
223
224impl AffectednessClass {
225    /// Classes 2/3/4 affect the current turn; 1 (independent) and 5 (spoofed)
226    /// do not — they integrate/persist or are suppressed without disturbing it.
227    pub fn affects_turn(self) -> bool {
228        matches!(
229            self,
230            AffectednessClass::InputAffecting
231                | AffectednessClass::OutputContended
232                | AffectednessClass::StructuralDependency
233        )
234    }
235
236    /// Stable lowercase string form.
237    pub fn as_str(self) -> &'static str {
238        match self {
239            AffectednessClass::Independent => "independent",
240            AffectednessClass::InputAffecting => "input_affecting",
241            AffectednessClass::OutputContended => "output_contended",
242            AffectednessClass::StructuralDependency => "structural_dependency",
243            AffectednessClass::ProvenanceSpoofed => "provenance_spoofed",
244        }
245    }
246}
247
248/// Resolve `(in_read, in_write)` membership for an op against the scope.
249///
250/// `#loop-guard-exchange-node-granularity`: when the scope carries an
251/// `exchange_tail_floor`, an `exchange` op is in scope only if it targets the
252/// active tail — its node index is at or above the floor (a tail append or tail
253/// edit). An op below the floor edits committed exchange history and is out of
254/// scope (→ `Independent`), so it never preempts the auto-loop drain. The
255/// `exchange` component is canonically in both the read set (tail) and the write
256/// set (append), so a tail op is in both. Without a floor, or for non-`exchange`
257/// components, fall back to whole-component / node-key overlap.
258fn scope_membership(
259    op_address: &Address,
260    op_index: Option<usize>,
261    scope: &TurnScope,
262) -> (bool, bool) {
263    if op_address.component == COMPONENT_EXCHANGE
264        && let Some(floor) = scope.exchange_tail_floor
265    {
266        let is_tail = op_index.is_some_and(|index| index >= floor);
267        return (is_tail, is_tail);
268    }
269    (
270        scope.read_set.iter().any(|a| op_address.overlaps(a)),
271        scope.write_set.iter().any(|a| op_address.overlaps(a)),
272    )
273}
274
275/// Classify a single operation against the current turn scope.
276///
277/// `affects(O,S) = conflict(O.target, read_set) ∨ conflict(O.target, write_set)`.
278/// An op that intersects no part of the scope is `Independent`. Within the
279/// scope, a `live_buffer` actor is `ProvenanceSpoofed` (a lagging sidecar, not a
280/// real edit), a `remove`/`move` is `StructuralDependency`, a read-set
281/// intersection is `InputAffecting`, and a write-set-only intersection is
282/// `OutputContended`.
283///
284/// `op_index` is the op's within-component node index (after-index for
285/// inserts/replaces, before-index for removes); it narrows `exchange`
286/// affectedness to the active tail (`#loop-guard-exchange-node-granularity`).
287pub fn classify_op(
288    actor: crate::op_log::OpActor,
289    op_kind: &str,
290    op_address: &Address,
291    op_index: Option<usize>,
292    scope: &TurnScope,
293) -> AffectednessClass {
294    let (in_read, in_write) = scope_membership(op_address, op_index, scope);
295    if !in_read && !in_write {
296        return AffectednessClass::Independent;
297    }
298    if actor == crate::op_log::OpActor::LiveBuffer {
299        return AffectednessClass::ProvenanceSpoofed;
300    }
301    if matches!(op_kind, "remove" | "move") {
302        return AffectednessClass::StructuralDependency;
303    }
304    if in_read {
305        return AffectednessClass::InputAffecting;
306    }
307    AffectednessClass::OutputContended
308}
309
310/// One classified operation in a cycle's affectedness summary.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct ClassifiedOp {
313    pub component: String,
314    pub node_key: String,
315    pub op_kind: String,
316    pub actor: crate::op_log::OpActor,
317    pub class: AffectednessClass,
318}
319
320/// Aggregate affectedness of a cycle's operations against the turn scope.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
322pub struct CycleAffectedness {
323    /// True when any op falls in a turn-affecting class (2/3/4).
324    pub turn_affected: bool,
325    /// Per-op classification, in input order.
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub classified: Vec<ClassifiedOp>,
328}
329
330/// The component-occurrence-narrowed address of a durable op.
331pub fn op_address(op: &crate::op_log::DocumentOp) -> Address {
332    Address::from_component_node_key(&op.component, &op.node_key)
333}
334
335/// Classify every op in a cycle against the turn scope, routing the coarse
336/// all-drift-is-affecting assumption into the 5-class taxonomy. Independent and
337/// provenance-spoofed ops do not affect the turn.
338pub fn classify_cycle(ops: &[crate::op_log::DocumentOp], scope: &TurnScope) -> CycleAffectedness {
339    let classified: Vec<ClassifiedOp> = ops
340        .iter()
341        .map(|op| {
342            let address = op_address(op);
343            let class = classify_op(op.actor, &op.op_kind, &address, op.node_index, scope);
344            ClassifiedOp {
345                component: op.component.clone(),
346                node_key: op.node_key.clone(),
347                op_kind: op.op_kind.clone(),
348                actor: op.actor,
349                class,
350            }
351        })
352        .collect();
353    let turn_affected = classified.iter().any(|op| op.class.affects_turn());
354    CycleAffectedness {
355        turn_affected,
356        classified,
357    }
358}
359
360fn dedupe_addresses(addresses: Vec<Address>) -> Vec<Address> {
361    let mut seen = Vec::new();
362    let mut out = Vec::new();
363    for address in addresses {
364        if seen.contains(&address) {
365            continue;
366        }
367        seen.push(address.clone());
368        out.push(address);
369    }
370    out
371}
372
373fn managed_output_write_set() -> Vec<Address> {
374    MANAGED_OUTPUT_COMPONENTS
375        .iter()
376        .map(|component| Address::component(component, 0))
377        .collect()
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn for_driver_with_queue_node_covers_input_and_output() {
386        let driver = Address::node(COMPONENT_QUEUE, 0, "queue:0:op-scoped-drift-2:0");
387        let scope = TurnScope::for_driver(Some(driver.clone()));
388        assert_eq!(scope.driver.as_ref(), Some(&driver));
389        // read set: driver first, then exchange tail.
390        assert_eq!(scope.read_set[0], driver);
391        assert!(
392            scope
393                .read_set
394                .contains(&Address::component(COMPONENT_EXCHANGE, 0))
395        );
396        // write set: managed outputs plus the consumed queue-item strike.
397        for &component in MANAGED_OUTPUT_COMPONENTS {
398            assert!(
399                scope.write_set.contains(&Address::component(component, 0)),
400                "write set must cover managed output component {component}"
401            );
402        }
403        assert!(scope.write_set.contains(&driver));
404    }
405
406    #[test]
407    fn managed_component_contract_names_every_conflict_surface() {
408        assert_eq!(
409            MANAGED_CONFLICT_COMPONENTS,
410            &[
411                COMPONENT_EXCHANGE,
412                COMPONENT_QUEUE,
413                COMPONENT_BACKLOG,
414                COMPONENT_STATUS,
415                COMPONENT_REVIEW,
416                COMPONENT_DONE,
417                COMPONENT_DONE_ARCHIVE,
418                COMPONENT_GITLINK,
419                COMPONENT_EDITOR_WRITEBACK,
420            ]
421        );
422    }
423
424    #[test]
425    fn for_driver_write_set_orders_managed_outputs_before_driver() {
426        let driver = Address::node(COMPONENT_QUEUE, 0, "queue:0:driver:0");
427        let scope = TurnScope::for_driver(Some(driver.clone()));
428        let mut expected = managed_output_write_set();
429        expected.push(driver);
430        assert_eq!(scope.write_set, expected);
431    }
432
433    #[test]
434    fn for_driver_without_node_only_lists_output_components() {
435        let scope = TurnScope::for_driver(None);
436        assert!(scope.driver.is_none());
437        assert_eq!(
438            scope.read_set,
439            vec![Address::component(COMPONENT_EXCHANGE, 0)]
440        );
441        assert_eq!(scope.write_set, managed_output_write_set());
442        // No queue node, so nothing addresses the queue component.
443        assert!(
444            scope
445                .write_set
446                .iter()
447                .all(|a| a.component != COMPONENT_QUEUE)
448        );
449    }
450
451    #[test]
452    fn addresses_are_deduped_preserving_order() {
453        let exchange = Address::component(COMPONENT_EXCHANGE, 0);
454        let deduped = dedupe_addresses(vec![
455            exchange.clone(),
456            Address::component(COMPONENT_BACKLOG, 0),
457            exchange.clone(),
458        ]);
459        assert_eq!(
460            deduped,
461            vec![exchange, Address::component(COMPONENT_BACKLOG, 0)]
462        );
463    }
464
465    #[test]
466    fn turn_scope_serde_round_trips() {
467        let scope = TurnScope::for_driver(Some(Address::node("queue", 0, "queue:0:alpha:0")));
468        let json = serde_json::to_string(&scope).unwrap();
469        let parsed: TurnScope = serde_json::from_str(&json).unwrap();
470        assert_eq!(parsed, scope);
471    }
472
473    #[test]
474    fn overlaps_matches_component_level_and_same_node() {
475        let comp = Address::component("queue", 0);
476        let node_a = Address::node("queue", 0, "queue:0:a:0");
477        let node_b = Address::node("queue", 0, "queue:0:b:0");
478        // component-level overlaps any node in the same occurrence.
479        assert!(comp.overlaps(&node_a));
480        assert!(node_a.overlaps(&comp));
481        // distinct nodes don't overlap.
482        assert!(!node_a.overlaps(&node_b));
483        // occurrence is informational: a whole-component address matches the
484        // component regardless of the occurrence field (#nm1x).
485        assert!(comp.overlaps(&Address::component("queue", 1)));
486        assert!(Address::component("queue", 1).overlaps(&node_a));
487        // different component never overlaps.
488        assert!(!comp.overlaps(&Address::component("backlog", 0)));
489    }
490
491    fn scope_for(node_key: &str) -> TurnScope {
492        TurnScope::for_driver(Some(Address::node("queue", 0, node_key)))
493    }
494
495    #[test]
496    fn classify_independent_when_outside_scope() {
497        use crate::op_log::OpActor;
498        let scope = scope_for("queue:0:driver:0");
499        // An insert into the icebox (not in any scope set) is independent.
500        let addr = Address::node("icebox", 0, "icebox:0:note:0");
501        assert_eq!(
502            classify_op(OpActor::User, "insert", &addr, None, &scope),
503            AffectednessClass::Independent
504        );
505        // A *different* queue node beside the running one is also independent.
506        let sibling = Address::node("queue", 0, "queue:0:other:0");
507        assert_eq!(
508            classify_op(OpActor::User, "insert", &sibling, None, &scope),
509            AffectednessClass::Independent
510        );
511    }
512
513    #[test]
514    fn classify_input_affecting_on_driver_edit() {
515        use crate::op_log::OpActor;
516        let scope = scope_for("queue:0:driver:0");
517        let driver = Address::node("queue", 0, "queue:0:driver:0");
518        assert_eq!(
519            classify_op(OpActor::User, "replace", &driver, None, &scope),
520            AffectednessClass::InputAffecting
521        );
522    }
523
524    #[test]
525    fn classify_structural_on_driver_removal() {
526        use crate::op_log::OpActor;
527        let scope = scope_for("queue:0:driver:0");
528        let driver = Address::node("queue", 0, "queue:0:driver:0");
529        assert_eq!(
530            classify_op(OpActor::User, "remove", &driver, None, &scope),
531            AffectednessClass::StructuralDependency
532        );
533    }
534
535    #[test]
536    fn classify_output_contended_on_write_set_only() {
537        use crate::op_log::OpActor;
538        let scope = scope_for("queue:0:driver:0");
539        // backlog is in the write set but not the read set.
540        let backlog = Address::node(COMPONENT_BACKLOG, 0, "backlog:0:item:0");
541        assert_eq!(
542            classify_op(
543                OpActor::ForeignSupervisor,
544                "replace",
545                &backlog,
546                None,
547                &scope
548            ),
549            AffectednessClass::OutputContended
550        );
551    }
552
553    #[test]
554    fn classify_all_managed_output_surfaces_by_component_scope() {
555        use crate::op_log::OpActor;
556
557        let scope = scope_for("queue:0:driver:0");
558        for &component in MANAGED_OUTPUT_COMPONENTS {
559            let addr = Address::node(component, 0, &format!("{component}:0:item:0"));
560            let expected = if component == COMPONENT_EXCHANGE {
561                AffectednessClass::InputAffecting
562            } else {
563                AffectednessClass::OutputContended
564            };
565            assert_eq!(
566                classify_op(OpActor::User, "replace", &addr, Some(0), &scope),
567                expected,
568                "component {component} should be classified within the managed scope"
569            );
570        }
571
572        let sibling_queue = Address::node(COMPONENT_QUEUE, 0, "queue:0:sibling:0");
573        assert_eq!(
574            classify_op(OpActor::User, "insert", &sibling_queue, Some(1), &scope),
575            AffectednessClass::Independent,
576            "queue remains node-scoped so unrelated queue work can merge"
577        );
578    }
579
580    #[test]
581    fn classify_provenance_spoofed_for_live_buffer_in_scope() {
582        use crate::op_log::OpActor;
583        let scope = scope_for("queue:0:driver:0");
584        // A live-buffer actor touching an in-scope address is spoofed, not contended.
585        let exchange = Address::component(COMPONENT_EXCHANGE, 0);
586        assert_eq!(
587            classify_op(OpActor::LiveBuffer, "replace", &exchange, None, &scope),
588            AffectednessClass::ProvenanceSpoofed
589        );
590        // ...but a live-buffer op outside scope is still just independent.
591        let icebox = Address::node("icebox", 0, "icebox:0:n:0");
592        assert_eq!(
593            classify_op(OpActor::LiveBuffer, "replace", &icebox, None, &scope),
594            AffectednessClass::Independent
595        );
596    }
597
598    fn exchange_tail_scope(floor: usize) -> TurnScope {
599        TurnScope::for_driver_with_exchange_tail(
600            Some(Address::node("queue", 0, "queue:0:driver:0")),
601            Some(floor),
602        )
603    }
604
605    #[test]
606    fn exchange_tail_floor_makes_old_block_edit_independent() {
607        // #loop-guard-exchange-node-granularity: with a tail floor of 2 (two
608        // committed exchange nodes), an edit to an OLD exchange block (index 0)
609        // must NOT affect the turn — it is committed history, not the active tail.
610        use crate::op_log::OpActor;
611        let scope = exchange_tail_scope(2);
612        let old_block = Address::node("exchange", 0, "exchange:0:ft-old:0");
613        assert_eq!(
614            classify_op(OpActor::User, "replace", &old_block, Some(0), &scope),
615            AffectednessClass::Independent
616        );
617        // The paired remove of the same old block is likewise independent.
618        assert_eq!(
619            classify_op(OpActor::User, "remove", &old_block, Some(0), &scope),
620            AffectednessClass::Independent
621        );
622    }
623
624    #[test]
625    fn exchange_tail_floor_keeps_tail_append_affecting() {
626        // A genuine mid-loop user prompt appended at the tail (index == floor)
627        // still affects the turn and must preempt the drain — no false negative.
628        use crate::op_log::OpActor;
629        let scope = exchange_tail_scope(2);
630        let tail_append = Address::node("exchange", 0, "exchange:0:ft-new:0");
631        assert_eq!(
632            classify_op(OpActor::User, "insert", &tail_append, Some(2), &scope),
633            AffectednessClass::InputAffecting
634        );
635        // An index beyond the floor (a second appended node) is also tail.
636        assert_eq!(
637            classify_op(OpActor::User, "insert", &tail_append, Some(5), &scope),
638            AffectednessClass::InputAffecting
639        );
640    }
641
642    #[test]
643    fn exchange_tail_floor_suppresses_live_buffer_tail_drift() {
644        // A live-buffer actor touching the tail is still ProvenanceSpoofed (a
645        // lagging sidecar), not a real contended edit.
646        use crate::op_log::OpActor;
647        let scope = exchange_tail_scope(2);
648        let tail = Address::node("exchange", 0, "exchange:0:ft-x:0");
649        assert_eq!(
650            classify_op(OpActor::LiveBuffer, "insert", &tail, Some(2), &scope),
651            AffectednessClass::ProvenanceSpoofed
652        );
653    }
654
655    #[test]
656    fn exchange_without_floor_keeps_whole_component_behavior() {
657        // No floor (None): any exchange op overlaps the whole-component address,
658        // preserving the conservative pre-narrowing behavior. Exchange is in the
659        // read set, so an exchange edit is InputAffecting regardless of index.
660        use crate::op_log::OpActor;
661        let scope = scope_for("queue:0:driver:0");
662        assert!(scope.exchange_tail_floor.is_none());
663        let old_block = Address::node("exchange", 0, "exchange:0:ft-old:0");
664        assert_eq!(
665            classify_op(OpActor::User, "replace", &old_block, Some(0), &scope),
666            AffectednessClass::InputAffecting
667        );
668    }
669
670    #[test]
671    fn classify_cycle_narrows_exchange_old_edit_but_keeps_tail() {
672        // End-to-end through classify_cycle: an old-block exchange edit plus a
673        // tail append. Only the tail append affects the turn.
674        use crate::op_log::{CausalClock, DocumentOp, OpActor};
675        let scope = exchange_tail_scope(2);
676        let mk = |node_key: &str, op_kind: &str, idx: Option<usize>| DocumentOp {
677            document_path: "plan.md".to_string(),
678            component: "exchange".to_string(),
679            node_key: node_key.to_string(),
680            node_index: idx,
681            item_id: String::new(),
682            op_kind: op_kind.to_string(),
683            actor: OpActor::User,
684            clock: CausalClock::default(),
685            before_preview: None,
686            after_preview: None,
687            recorded_at: None,
688        };
689        let old_only = vec![mk("exchange:0:ft-old:0", "replace", Some(0))];
690        assert!(
691            !classify_cycle(&old_only, &scope).turn_affected,
692            "an old-block exchange edit alone must not affect the turn"
693        );
694        let with_tail = vec![
695            mk("exchange:0:ft-old:0", "replace", Some(0)),
696            mk("exchange:0:ft-new:0", "insert", Some(2)),
697        ];
698        assert!(
699            classify_cycle(&with_tail, &scope).turn_affected,
700            "a tail append must still affect the turn"
701        );
702    }
703
704    #[test]
705    fn classify_cycle_aggregates_turn_affected() {
706        use crate::op_log::{CausalClock, DocumentOp, OpActor};
707        let scope = scope_for("queue:0:driver:0");
708        let mk = |component: &str, node_key: &str, op_kind: &str, actor: OpActor| DocumentOp {
709            document_path: "plan.md".to_string(),
710            component: component.to_string(),
711            node_key: node_key.to_string(),
712            node_index: None,
713            item_id: String::new(),
714            op_kind: op_kind.to_string(),
715            actor,
716            clock: CausalClock::default(),
717            before_preview: None,
718            after_preview: None,
719            recorded_at: None,
720        };
721        // Independent op only -> turn not affected.
722        let independent = vec![mk("queue", "queue:0:other:0", "insert", OpActor::User)];
723        assert!(!classify_cycle(&independent, &scope).turn_affected);
724
725        // Add an input-affecting driver edit -> turn affected.
726        let mixed = vec![
727            mk("queue", "queue:0:other:0", "insert", OpActor::User),
728            mk("queue", "queue:0:driver:0", "replace", OpActor::User),
729        ];
730        let summary = classify_cycle(&mixed, &scope);
731        assert!(summary.turn_affected);
732        assert_eq!(summary.classified.len(), 2);
733        assert_eq!(summary.classified[0].class, AffectednessClass::Independent);
734        assert_eq!(
735            summary.classified[1].class,
736            AffectednessClass::InputAffecting
737        );
738    }
739
740    #[test]
741    fn affects_turn_matches_taxonomy() {
742        assert!(!AffectednessClass::Independent.affects_turn());
743        assert!(!AffectednessClass::ProvenanceSpoofed.affects_turn());
744        assert!(AffectednessClass::InputAffecting.affects_turn());
745        assert!(AffectednessClass::OutputContended.affects_turn());
746        assert!(AffectednessClass::StructuralDependency.affects_turn());
747    }
748}