Skip to main content

ito_domain/audit/
event.rs

1//! Audit event types and builder.
2//!
3//! Defines the core `AuditEvent` struct and associated enums for the
4//! append-only audit log. Events are serialized as single-line JSON objects
5//! (JSONL) and are never modified or deleted after creation.
6
7use serde::{Deserialize, Serialize};
8
9/// Current schema version. Bumped only on breaking changes.
10pub const SCHEMA_VERSION: u32 = 1;
11
12/// A single audit event recording a domain state transition.
13///
14/// Events are append-only: once written, they are never modified or deleted.
15/// Corrections are recorded as new compensating events.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct AuditEvent {
18    /// Schema version (currently 1).
19    pub v: u32,
20    /// UTC timestamp in RFC 3339 format with millisecond precision.
21    pub ts: String,
22    /// Entity type (task, change, module, wave, planning, config).
23    pub entity: String,
24    /// Entity identifier (task id, change id, module id, config key, etc.).
25    pub entity_id: String,
26    /// Scoping context — the change_id for task/wave events, None for global entities.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub scope: Option<String>,
29    /// Operation type (e.g., status_change, create, archive).
30    pub op: String,
31    /// Previous state value (None for create operations).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub from: Option<String>,
34    /// New state value.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub to: Option<String>,
37    /// Mutation source (cli, reconcile, ralph).
38    pub actor: String,
39    /// User/agent identity (e.g., @jack).
40    pub by: String,
41    /// Optional operation-specific metadata.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub meta: Option<serde_json::Value>,
44    /// Number of equivalent adjacent events represented by this event.
45    #[serde(default = "default_count", skip_serializing_if = "is_default_count")]
46    pub count: u64,
47    /// Session and git context for traceability.
48    pub ctx: EventContext,
49}
50
51fn is_default_count(count: &u64) -> bool {
52    *count <= 1
53}
54
55fn default_count() -> u64 {
56    1
57}
58
59/// Known entity types for the audit log.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum EntityType {
63    /// A task within a change.
64    Task,
65    /// A change (proposal + specs + tasks).
66    Change,
67    /// A module grouping related changes.
68    Module,
69    /// A wave within a task plan.
70    Wave,
71    /// A planning entry (decision, blocker, note, etc.).
72    Planning,
73    /// A configuration key.
74    Config,
75}
76
77impl EntityType {
78    /// Returns the string representation used in event serialization.
79    pub fn as_str(&self) -> &'static str {
80        match self {
81            EntityType::Task => "task",
82            EntityType::Change => "change",
83            EntityType::Module => "module",
84            EntityType::Wave => "wave",
85            EntityType::Planning => "planning",
86            EntityType::Config => "config",
87        }
88    }
89}
90
91impl std::fmt::Display for EntityType {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.write_str(self.as_str())
94    }
95}
96
97/// Known actor types for the audit log.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum Actor {
101    /// Event emitted from a normal CLI command.
102    Cli,
103    /// Compensating event emitted by reconciliation.
104    Reconcile,
105    /// Event emitted by the Ralph automation loop.
106    Ralph,
107}
108
109impl Actor {
110    /// Returns the string representation used in event serialization.
111    pub fn as_str(&self) -> &'static str {
112        match self {
113            Actor::Cli => "cli",
114            Actor::Reconcile => "reconcile",
115            Actor::Ralph => "ralph",
116        }
117    }
118}
119
120impl std::fmt::Display for Actor {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.write_str(self.as_str())
123    }
124}
125
126/// Session and git context captured at event-write time.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct EventContext {
129    /// Ito-generated UUID v4 per CLI process group.
130    pub session_id: String,
131    /// Optional harness session ID (from env vars).
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub harness_session_id: Option<String>,
134    /// Current git branch name (None if detached HEAD).
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub branch: Option<String>,
137    /// Worktree name if not the main worktree.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub worktree: Option<String>,
140    /// Short HEAD commit hash.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub commit: Option<String>,
143}
144
145/// Information about a git worktree for multi-worktree streaming.
146#[derive(Debug, Clone, PartialEq)]
147pub struct WorktreeInfo {
148    /// Worktree filesystem path.
149    pub path: std::path::PathBuf,
150    /// Branch checked out in this worktree (None if detached).
151    pub branch: Option<String>,
152    /// Whether this is the main worktree.
153    pub is_main: bool,
154}
155
156/// An audit event tagged with its source worktree (used during streaming).
157#[derive(Debug, Clone)]
158pub struct TaggedAuditEvent {
159    /// The audit event.
160    pub event: AuditEvent,
161    /// The worktree this event was read from.
162    pub source: WorktreeInfo,
163}
164
165/// Operation type constants for each entity.
166///
167/// These are used as the `op` field in `AuditEvent`. Using constants instead
168/// of free-form strings prevents typos and enables exhaustive matching.
169pub mod ops {
170    // Task operations
171    /// Task created.
172    pub const TASK_CREATE: &str = "create";
173    /// Task status changed.
174    pub const TASK_STATUS_CHANGE: &str = "status_change";
175    /// Task added to an existing plan.
176    pub const TASK_ADD: &str = "add";
177
178    // Change operations
179    /// Change created.
180    pub const CHANGE_CREATE: &str = "create";
181    /// Change archived.
182    pub const CHANGE_ARCHIVE: &str = "archive";
183
184    // Module operations
185    /// Module created.
186    pub const MODULE_CREATE: &str = "create";
187    /// Change added to a module.
188    pub const MODULE_CHANGE_ADDED: &str = "change_added";
189    /// Change completed within a module.
190    pub const MODULE_CHANGE_COMPLETED: &str = "change_completed";
191
192    // Wave operations
193    /// Wave unlocked (all predecessors complete).
194    pub const WAVE_UNLOCK: &str = "unlock";
195
196    // Planning operations
197    /// Planning decision recorded.
198    pub const PLANNING_DECISION: &str = "decision";
199    /// Planning blocker recorded.
200    pub const PLANNING_BLOCKER: &str = "blocker";
201    /// Planning question recorded.
202    pub const PLANNING_QUESTION: &str = "question";
203    /// Planning note recorded.
204    pub const PLANNING_NOTE: &str = "note";
205    /// Planning focus changed.
206    pub const PLANNING_FOCUS_CHANGE: &str = "focus_change";
207
208    // Config operations
209    /// Config key set.
210    pub const CONFIG_SET: &str = "set";
211    /// Config key unset.
212    pub const CONFIG_UNSET: &str = "unset";
213
214    // Reconciliation
215    /// Reconciliation compensating event.
216    pub const RECONCILED: &str = "reconciled";
217}
218
219/// Builder for constructing `AuditEvent` instances.
220///
221/// Auto-populates `v` (schema version), `ts` (UTC now), and enforces
222/// required fields at build time.
223pub struct AuditEventBuilder {
224    entity: Option<EntityType>,
225    entity_id: Option<String>,
226    scope: Option<String>,
227    op: Option<String>,
228    from: Option<String>,
229    to: Option<String>,
230    actor: Option<Actor>,
231    by: Option<String>,
232    meta: Option<serde_json::Value>,
233    ctx: Option<EventContext>,
234}
235
236impl AuditEventBuilder {
237    /// Create a new builder with defaults.
238    pub fn new() -> Self {
239        Self {
240            entity: None,
241            entity_id: None,
242            scope: None,
243            op: None,
244            from: None,
245            to: None,
246            actor: None,
247            by: None,
248            meta: None,
249            ctx: None,
250        }
251    }
252
253    /// Set the entity type.
254    pub fn entity(mut self, entity: EntityType) -> Self {
255        self.entity = Some(entity);
256        self
257    }
258
259    /// Set the entity identifier.
260    pub fn entity_id(mut self, id: impl Into<String>) -> Self {
261        self.entity_id = Some(id.into());
262        self
263    }
264
265    /// Set the scope (change_id for task/wave events).
266    pub fn scope(mut self, scope: impl Into<String>) -> Self {
267        self.scope = Some(scope.into());
268        self
269    }
270
271    /// Set the operation type.
272    pub fn op(mut self, op: impl Into<String>) -> Self {
273        self.op = Some(op.into());
274        self
275    }
276
277    /// Set the previous state value.
278    pub fn from(mut self, from: impl Into<String>) -> Self {
279        self.from = Some(from.into());
280        self
281    }
282
283    /// Set the new state value.
284    pub fn to(mut self, to: impl Into<String>) -> Self {
285        self.to = Some(to.into());
286        self
287    }
288
289    /// Set the actor.
290    pub fn actor(mut self, actor: Actor) -> Self {
291        self.actor = Some(actor);
292        self
293    }
294
295    /// Set the user identity.
296    pub fn by(mut self, by: impl Into<String>) -> Self {
297        self.by = Some(by.into());
298        self
299    }
300
301    /// Set optional metadata.
302    pub fn meta(mut self, meta: serde_json::Value) -> Self {
303        self.meta = Some(meta);
304        self
305    }
306
307    /// Set the event context.
308    pub fn ctx(mut self, ctx: EventContext) -> Self {
309        self.ctx = Some(ctx);
310        self
311    }
312
313    /// Build the `AuditEvent`, using the current UTC time for `ts`.
314    ///
315    /// Returns `None` if required fields (entity, entity_id, op, actor, by, ctx)
316    /// are missing.
317    pub fn build(self) -> Option<AuditEvent> {
318        let entity = self.entity?;
319        let entity_id = self.entity_id?;
320        let op = self.op?;
321        let actor = self.actor?;
322        let by = self.by?;
323        let ctx = self.ctx?;
324
325        let ts = chrono::Utc::now()
326            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
327            .to_string();
328
329        Some(AuditEvent {
330            v: SCHEMA_VERSION,
331            ts,
332            entity: entity.as_str().to_string(),
333            entity_id,
334            scope: self.scope,
335            op,
336            from: self.from,
337            to: self.to,
338            actor: actor.as_str().to_string(),
339            by,
340            meta: self.meta,
341            count: 1,
342            ctx,
343        })
344    }
345}
346
347impl Default for AuditEventBuilder {
348    fn default() -> Self {
349        Self::new()
350    }
351}
352
353#[cfg(test)]
354#[path = "event_tests.rs"]
355mod event_tests;