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}