Skip to main content

agent_doc_core/
op_log.rs

1//! # Module: op_log
2//!
3//! Durable operation-log data types — actor + causal (Lamport / session-origin)
4//! tagging for document node operations (`#op-scoped-drift-1`, phase 1 of the
5//! operation-scoped drift model in `tasks/agent-doc/plan-operation-scoped-drift.md`).
6//!
7//! ## Spec
8//! - `OpActor` names who produced a document operation: the managing `agent`,
9//!   the `user`, a concurrent `foreign_supervisor`, or a lagging `live_buffer`
10//!   editor sidecar.
11//! - `OpSource` is the signal a caller already has about where an op came from;
12//!   `classify_actor` maps it to an `OpActor`. Phase-1 rules are source-driven:
13//!   a snapshot↔document divergence observed at preflight is a `user` edit,
14//!   because the agent's own committed output already lives in the snapshot.
15//! - `CausalClock` carries the Lamport logical tick plus the originating
16//!   session id. The durable store (`agent-doc-sqlite`) owns Lamport assignment;
17//!   callers populate `origin_session` and leave `lamport` as a placeholder.
18//! - `DocumentOp` is the durable record persisted to the sqlite op log. It is
19//!   pure data so the sqlite writer and orchestration callers share one schema.
20//!
21//! ## Agentic Contracts
22//! - Actor classification never blocks a cycle; it only annotates ops.
23//! - The op-log substrate is additive: phase 2 (TurnScope) and phase 3 (the
24//!   affectedness classifier) read these records but phase 1 only writes them.
25
26use serde::{Deserialize, Serialize};
27
28/// Who produced a document operation.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum OpActor {
32    /// The managing agent's own write-back (response append, status, pending).
33    Agent,
34    /// A genuine human edit observed between cycles.
35    User,
36    /// A concurrent agent-doc supervisor writing the same document.
37    ForeignSupervisor,
38    /// A lagging editor live-buffer sidecar disk write (provenance-spoofed drift).
39    LiveBuffer,
40}
41
42impl OpActor {
43    /// Stable lowercase string form used in the durable store and JSON.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            OpActor::Agent => "agent",
47            OpActor::User => "user",
48            OpActor::ForeignSupervisor => "foreign_supervisor",
49            OpActor::LiveBuffer => "live_buffer",
50        }
51    }
52
53    /// Parse the stable string form back into an actor.
54    pub fn from_str_lenient(value: &str) -> Option<Self> {
55        match value {
56            "agent" => Some(OpActor::Agent),
57            "user" => Some(OpActor::User),
58            "foreign_supervisor" => Some(OpActor::ForeignSupervisor),
59            "live_buffer" => Some(OpActor::LiveBuffer),
60            _ => None,
61        }
62    }
63}
64
65/// The provenance signal a caller has when it observes a batch of ops.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum OpSource {
68    /// snapshot↔document divergence observed at preflight (a user edit).
69    SnapshotDiff,
70    /// a lagging live-buffer sidecar disk write (class-5 spoofed drift).
71    LiveBufferDrift,
72    /// a concurrent foreign supervisor write contending the same document.
73    ForeignSupervisorWrite,
74    /// the managing agent's own write-back.
75    AgentWrite,
76}
77
78/// Map a provenance signal to the actor that produced the op.
79///
80/// Phase-1 rules are intentionally source-driven. Component-role refinement
81/// (e.g. distinguishing an agent output append from a user edit *within* the
82/// same snapshot diff) is deferred to the phase-3 affectedness classifier;
83/// at preflight the committed snapshot already contains the agent's last
84/// output, so any snapshot↔document divergence is a user edit.
85pub fn classify_actor(source: OpSource) -> OpActor {
86    match source {
87        OpSource::SnapshotDiff => OpActor::User,
88        OpSource::LiveBufferDrift => OpActor::LiveBuffer,
89        OpSource::ForeignSupervisorWrite => OpActor::ForeignSupervisor,
90        OpSource::AgentWrite => OpActor::Agent,
91    }
92}
93
94/// Lamport logical clock plus the originating session id.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
96pub struct CausalClock {
97    /// Monotonic per-document logical tick. The durable store assigns the
98    /// authoritative value; callers may leave this `0`.
99    pub lamport: u64,
100    /// `agent_doc_session` of the session that produced the op, when known.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub origin_session: Option<String>,
103}
104
105/// A durable record of one node-keyed document operation.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct DocumentOp {
108    /// Path of the session document the op applies to.
109    pub document_path: String,
110    /// Component the node lives in (`queue`, `exchange`, `backlog`, …).
111    pub component: String,
112    /// Stable node key (`component:occurrence:item-id:dup`).
113    pub node_key: String,
114    /// Position of the node within its component (after-index for inserts/
115    /// replaces, before-index for removes). Carries the tail-vs-old-block signal
116    /// the affectedness classifier needs to narrow `exchange` to its active tail
117    /// (`#loop-guard-exchange-node-granularity`). `None` when the source did not
118    /// supply an index (e.g. an op replayed from the durable store, which never
119    /// feeds the live classifier).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub node_index: Option<usize>,
122    /// Backlog/queue item id, when the node carries one.
123    #[serde(default, skip_serializing_if = "String::is_empty")]
124    pub item_id: String,
125    /// Operation kind string (`insert`, `remove`, `replace`, `move`, `strike`,
126    /// `unstrike`) mirroring the markdown-AST node-event vocabulary.
127    pub op_kind: String,
128    /// Who produced the op.
129    pub actor: OpActor,
130    /// Lamport tick + originating session.
131    pub clock: CausalClock,
132    /// Bounded preview of the node content before the op.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub before_preview: Option<String>,
135    /// Bounded preview of the node content after the op.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub after_preview: Option<String>,
138    /// Wall-clock timestamp the op was recorded, when the caller supplies one.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub recorded_at: Option<String>,
141}
142
143impl DocumentOp {
144    /// True when two ops describe the same node-level mutation (ignoring the
145    /// writer-assigned Lamport tick and recording time). Used by the durable
146    /// store to keep repeated preflight passes over the same uncommitted diff
147    /// idempotent.
148    pub fn same_mutation(&self, other: &DocumentOp) -> bool {
149        self.document_path == other.document_path
150            && self.component == other.component
151            && self.node_key == other.node_key
152            && self.op_kind == other.op_kind
153            && self.before_preview == other.before_preview
154            && self.after_preview == other.after_preview
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn classify_actor_is_source_driven() {
164        assert_eq!(classify_actor(OpSource::SnapshotDiff), OpActor::User);
165        assert_eq!(
166            classify_actor(OpSource::LiveBufferDrift),
167            OpActor::LiveBuffer
168        );
169        assert_eq!(
170            classify_actor(OpSource::ForeignSupervisorWrite),
171            OpActor::ForeignSupervisor
172        );
173        assert_eq!(classify_actor(OpSource::AgentWrite), OpActor::Agent);
174    }
175
176    #[test]
177    fn op_actor_string_round_trips() {
178        for actor in [
179            OpActor::Agent,
180            OpActor::User,
181            OpActor::ForeignSupervisor,
182            OpActor::LiveBuffer,
183        ] {
184            assert_eq!(OpActor::from_str_lenient(actor.as_str()), Some(actor));
185        }
186        assert_eq!(OpActor::from_str_lenient("nonsense"), None);
187    }
188
189    #[test]
190    fn document_op_serde_round_trips() {
191        let op = DocumentOp {
192            document_path: "plan.md".to_string(),
193            component: "queue".to_string(),
194            node_key: "queue:0:beta:0".to_string(),
195            node_index: Some(1),
196            item_id: "beta".to_string(),
197            op_kind: "insert".to_string(),
198            actor: OpActor::User,
199            clock: CausalClock {
200                lamport: 0,
201                origin_session: Some("sess-1".to_string()),
202            },
203            before_preview: None,
204            after_preview: Some("- do [#beta]".to_string()),
205            recorded_at: Some("123".to_string()),
206        };
207        let json = serde_json::to_string(&op).unwrap();
208        let parsed: DocumentOp = serde_json::from_str(&json).unwrap();
209        assert_eq!(parsed, op);
210        // actor renders as the stable snake_case string in JSON.
211        assert!(json.contains("\"actor\":\"user\""));
212    }
213
214    #[test]
215    fn same_mutation_ignores_clock_and_time() {
216        let base = DocumentOp {
217            document_path: "plan.md".to_string(),
218            component: "queue".to_string(),
219            node_key: "queue:0:beta:0".to_string(),
220            node_index: None,
221            item_id: "beta".to_string(),
222            op_kind: "insert".to_string(),
223            actor: OpActor::User,
224            clock: CausalClock {
225                lamport: 5,
226                origin_session: Some("sess-1".to_string()),
227            },
228            before_preview: None,
229            after_preview: Some("- do [#beta]".to_string()),
230            recorded_at: Some("100".to_string()),
231        };
232        let mut later = base.clone();
233        later.clock.lamport = 99;
234        later.recorded_at = Some("200".to_string());
235        assert!(base.same_mutation(&later));
236
237        let mut different = base.clone();
238        different.after_preview = Some("- do [#gamma]".to_string());
239        assert!(!base.same_mutation(&different));
240    }
241}