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    /// Session and git context for traceability.
45    pub ctx: EventContext,
46}
47
48/// Known entity types for the audit log.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum EntityType {
52    /// A task within a change.
53    Task,
54    /// A change (proposal + specs + tasks).
55    Change,
56    /// A module grouping related changes.
57    Module,
58    /// A wave within a task plan.
59    Wave,
60    /// A planning entry (decision, blocker, note, etc.).
61    Planning,
62    /// A configuration key.
63    Config,
64}
65
66impl EntityType {
67    /// Returns the string representation used in event serialization.
68    pub fn as_str(&self) -> &'static str {
69        match self {
70            EntityType::Task => "task",
71            EntityType::Change => "change",
72            EntityType::Module => "module",
73            EntityType::Wave => "wave",
74            EntityType::Planning => "planning",
75            EntityType::Config => "config",
76        }
77    }
78}
79
80impl std::fmt::Display for EntityType {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86/// Known actor types for the audit log.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum Actor {
90    /// Event emitted from a normal CLI command.
91    Cli,
92    /// Compensating event emitted by reconciliation.
93    Reconcile,
94    /// Event emitted by the Ralph automation loop.
95    Ralph,
96}
97
98impl Actor {
99    /// Returns the string representation used in event serialization.
100    pub fn as_str(&self) -> &'static str {
101        match self {
102            Actor::Cli => "cli",
103            Actor::Reconcile => "reconcile",
104            Actor::Ralph => "ralph",
105        }
106    }
107}
108
109impl std::fmt::Display for Actor {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.write_str(self.as_str())
112    }
113}
114
115/// Session and git context captured at event-write time.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub struct EventContext {
118    /// Ito-generated UUID v4 per CLI process group.
119    pub session_id: String,
120    /// Optional harness session ID (from env vars).
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub harness_session_id: Option<String>,
123    /// Current git branch name (None if detached HEAD).
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub branch: Option<String>,
126    /// Worktree name if not the main worktree.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub worktree: Option<String>,
129    /// Short HEAD commit hash.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub commit: Option<String>,
132}
133
134/// Information about a git worktree for multi-worktree streaming.
135#[derive(Debug, Clone, PartialEq)]
136pub struct WorktreeInfo {
137    /// Worktree filesystem path.
138    pub path: std::path::PathBuf,
139    /// Branch checked out in this worktree (None if detached).
140    pub branch: Option<String>,
141    /// Whether this is the main worktree.
142    pub is_main: bool,
143}
144
145/// An audit event tagged with its source worktree (used during streaming).
146#[derive(Debug, Clone)]
147pub struct TaggedAuditEvent {
148    /// The audit event.
149    pub event: AuditEvent,
150    /// The worktree this event was read from.
151    pub source: WorktreeInfo,
152}
153
154/// Operation type constants for each entity.
155///
156/// These are used as the `op` field in `AuditEvent`. Using constants instead
157/// of free-form strings prevents typos and enables exhaustive matching.
158pub mod ops {
159    // Task operations
160    /// Task created.
161    pub const TASK_CREATE: &str = "create";
162    /// Task status changed.
163    pub const TASK_STATUS_CHANGE: &str = "status_change";
164    /// Task added to an existing plan.
165    pub const TASK_ADD: &str = "add";
166
167    // Change operations
168    /// Change created.
169    pub const CHANGE_CREATE: &str = "create";
170    /// Change archived.
171    pub const CHANGE_ARCHIVE: &str = "archive";
172
173    // Module operations
174    /// Module created.
175    pub const MODULE_CREATE: &str = "create";
176    /// Change added to a module.
177    pub const MODULE_CHANGE_ADDED: &str = "change_added";
178    /// Change completed within a module.
179    pub const MODULE_CHANGE_COMPLETED: &str = "change_completed";
180
181    // Wave operations
182    /// Wave unlocked (all predecessors complete).
183    pub const WAVE_UNLOCK: &str = "unlock";
184
185    // Planning operations
186    /// Planning decision recorded.
187    pub const PLANNING_DECISION: &str = "decision";
188    /// Planning blocker recorded.
189    pub const PLANNING_BLOCKER: &str = "blocker";
190    /// Planning question recorded.
191    pub const PLANNING_QUESTION: &str = "question";
192    /// Planning note recorded.
193    pub const PLANNING_NOTE: &str = "note";
194    /// Planning focus changed.
195    pub const PLANNING_FOCUS_CHANGE: &str = "focus_change";
196
197    // Config operations
198    /// Config key set.
199    pub const CONFIG_SET: &str = "set";
200    /// Config key unset.
201    pub const CONFIG_UNSET: &str = "unset";
202
203    // Reconciliation
204    /// Reconciliation compensating event.
205    pub const RECONCILED: &str = "reconciled";
206}
207
208/// Builder for constructing `AuditEvent` instances.
209///
210/// Auto-populates `v` (schema version), `ts` (UTC now), and enforces
211/// required fields at build time.
212pub struct AuditEventBuilder {
213    entity: Option<EntityType>,
214    entity_id: Option<String>,
215    scope: Option<String>,
216    op: Option<String>,
217    from: Option<String>,
218    to: Option<String>,
219    actor: Option<Actor>,
220    by: Option<String>,
221    meta: Option<serde_json::Value>,
222    ctx: Option<EventContext>,
223}
224
225impl AuditEventBuilder {
226    /// Create a new builder with defaults.
227    pub fn new() -> Self {
228        Self {
229            entity: None,
230            entity_id: None,
231            scope: None,
232            op: None,
233            from: None,
234            to: None,
235            actor: None,
236            by: None,
237            meta: None,
238            ctx: None,
239        }
240    }
241
242    /// Set the entity type.
243    pub fn entity(mut self, entity: EntityType) -> Self {
244        self.entity = Some(entity);
245        self
246    }
247
248    /// Set the entity identifier.
249    pub fn entity_id(mut self, id: impl Into<String>) -> Self {
250        self.entity_id = Some(id.into());
251        self
252    }
253
254    /// Set the scope (change_id for task/wave events).
255    pub fn scope(mut self, scope: impl Into<String>) -> Self {
256        self.scope = Some(scope.into());
257        self
258    }
259
260    /// Set the operation type.
261    pub fn op(mut self, op: impl Into<String>) -> Self {
262        self.op = Some(op.into());
263        self
264    }
265
266    /// Set the previous state value.
267    pub fn from(mut self, from: impl Into<String>) -> Self {
268        self.from = Some(from.into());
269        self
270    }
271
272    /// Set the new state value.
273    pub fn to(mut self, to: impl Into<String>) -> Self {
274        self.to = Some(to.into());
275        self
276    }
277
278    /// Set the actor.
279    pub fn actor(mut self, actor: Actor) -> Self {
280        self.actor = Some(actor);
281        self
282    }
283
284    /// Set the user identity.
285    pub fn by(mut self, by: impl Into<String>) -> Self {
286        self.by = Some(by.into());
287        self
288    }
289
290    /// Set optional metadata.
291    pub fn meta(mut self, meta: serde_json::Value) -> Self {
292        self.meta = Some(meta);
293        self
294    }
295
296    /// Set the event context.
297    pub fn ctx(mut self, ctx: EventContext) -> Self {
298        self.ctx = Some(ctx);
299        self
300    }
301
302    /// Build the `AuditEvent`, using the current UTC time for `ts`.
303    ///
304    /// Returns `None` if required fields (entity, entity_id, op, actor, by, ctx)
305    /// are missing.
306    pub fn build(self) -> Option<AuditEvent> {
307        let entity = self.entity?;
308        let entity_id = self.entity_id?;
309        let op = self.op?;
310        let actor = self.actor?;
311        let by = self.by?;
312        let ctx = self.ctx?;
313
314        let ts = chrono::Utc::now()
315            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
316            .to_string();
317
318        Some(AuditEvent {
319            v: SCHEMA_VERSION,
320            ts,
321            entity: entity.as_str().to_string(),
322            entity_id,
323            scope: self.scope,
324            op,
325            from: self.from,
326            to: self.to,
327            actor: actor.as_str().to_string(),
328            by,
329            meta: self.meta,
330            ctx,
331        })
332    }
333}
334
335impl Default for AuditEventBuilder {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn test_ctx() -> EventContext {
346        EventContext {
347            session_id: "test-session-id".to_string(),
348            harness_session_id: None,
349            branch: Some("main".to_string()),
350            worktree: None,
351            commit: Some("abc12345".to_string()),
352        }
353    }
354
355    #[test]
356    fn audit_event_round_trip_serialization() {
357        let event = AuditEvent {
358            v: 1,
359            ts: "2026-02-08T14:30:00.000Z".to_string(),
360            entity: "task".to_string(),
361            entity_id: "2.1".to_string(),
362            scope: Some("009-02_audit-log".to_string()),
363            op: "status_change".to_string(),
364            from: Some("pending".to_string()),
365            to: Some("in-progress".to_string()),
366            actor: "cli".to_string(),
367            by: "@jack".to_string(),
368            meta: None,
369            ctx: test_ctx(),
370        };
371
372        let json = serde_json::to_string(&event).expect("serialize");
373        let parsed: AuditEvent = serde_json::from_str(&json).expect("deserialize");
374        assert_eq!(event, parsed);
375    }
376
377    #[test]
378    fn audit_event_serializes_to_single_line() {
379        let event = AuditEvent {
380            v: 1,
381            ts: "2026-02-08T14:30:00.000Z".to_string(),
382            entity: "task".to_string(),
383            entity_id: "1.1".to_string(),
384            scope: Some("test-change".to_string()),
385            op: "create".to_string(),
386            from: None,
387            to: Some("pending".to_string()),
388            actor: "cli".to_string(),
389            by: "@test".to_string(),
390            meta: None,
391            ctx: test_ctx(),
392        };
393
394        let json = serde_json::to_string(&event).expect("serialize");
395        assert!(!json.contains('\n'));
396    }
397
398    #[test]
399    fn optional_fields_omitted_when_none() {
400        let event = AuditEvent {
401            v: 1,
402            ts: "2026-02-08T14:30:00.000Z".to_string(),
403            entity: "change".to_string(),
404            entity_id: "test".to_string(),
405            scope: None,
406            op: "create".to_string(),
407            from: None,
408            to: None,
409            actor: "cli".to_string(),
410            by: "@test".to_string(),
411            meta: None,
412            ctx: EventContext {
413                session_id: "sid".to_string(),
414                harness_session_id: None,
415                branch: None,
416                worktree: None,
417                commit: None,
418            },
419        };
420
421        let json = serde_json::to_string(&event).expect("serialize");
422        assert!(!json.contains("scope"));
423        assert!(!json.contains("from"));
424        assert!(!json.contains("\"to\""));
425        assert!(!json.contains("meta"));
426        assert!(!json.contains("harness_session_id"));
427        assert!(!json.contains("branch"));
428        assert!(!json.contains("worktree"));
429        assert!(!json.contains("commit"));
430    }
431
432    #[test]
433    fn entity_type_serializes_to_lowercase() {
434        let json = serde_json::to_string(&EntityType::Task).expect("serialize");
435        assert_eq!(json, "\"task\"");
436
437        let json = serde_json::to_string(&EntityType::Config).expect("serialize");
438        assert_eq!(json, "\"config\"");
439    }
440
441    #[test]
442    fn entity_type_round_trip() {
443        let variants = [
444            EntityType::Task,
445            EntityType::Change,
446            EntityType::Module,
447            EntityType::Wave,
448            EntityType::Planning,
449            EntityType::Config,
450        ];
451        for variant in variants {
452            let json = serde_json::to_string(&variant).expect("serialize");
453            let parsed: EntityType = serde_json::from_str(&json).expect("deserialize");
454            assert_eq!(variant, parsed);
455        }
456    }
457
458    #[test]
459    fn actor_serializes_to_lowercase() {
460        assert_eq!(Actor::Cli.as_str(), "cli");
461        assert_eq!(Actor::Reconcile.as_str(), "reconcile");
462        assert_eq!(Actor::Ralph.as_str(), "ralph");
463    }
464
465    #[test]
466    fn actor_round_trip() {
467        let variants = [Actor::Cli, Actor::Reconcile, Actor::Ralph];
468        for variant in variants {
469            let json = serde_json::to_string(&variant).expect("serialize");
470            let parsed: Actor = serde_json::from_str(&json).expect("deserialize");
471            assert_eq!(variant, parsed);
472        }
473    }
474
475    #[test]
476    fn builder_produces_valid_event() {
477        let event = AuditEventBuilder::new()
478            .entity(EntityType::Task)
479            .entity_id("1.1")
480            .scope("test-change")
481            .op(ops::TASK_STATUS_CHANGE)
482            .from("pending")
483            .to("in-progress")
484            .actor(Actor::Cli)
485            .by("@jack")
486            .ctx(test_ctx())
487            .build()
488            .expect("should build");
489
490        assert_eq!(event.v, SCHEMA_VERSION);
491        assert_eq!(event.entity, "task");
492        assert_eq!(event.entity_id, "1.1");
493        assert_eq!(event.scope, Some("test-change".to_string()));
494        assert_eq!(event.op, "status_change");
495        assert_eq!(event.from, Some("pending".to_string()));
496        assert_eq!(event.to, Some("in-progress".to_string()));
497        assert_eq!(event.actor, "cli");
498        assert_eq!(event.by, "@jack");
499        assert!(!event.ts.is_empty());
500    }
501
502    #[test]
503    fn builder_returns_none_without_required_fields() {
504        assert!(AuditEventBuilder::new().build().is_none());
505
506        // Missing entity_id
507        assert!(
508            AuditEventBuilder::new()
509                .entity(EntityType::Task)
510                .op("create")
511                .actor(Actor::Cli)
512                .by("@test")
513                .ctx(test_ctx())
514                .build()
515                .is_none()
516        );
517    }
518
519    #[test]
520    fn builder_with_meta() {
521        let meta = serde_json::json!({"wave": 2, "name": "Test task"});
522        let event = AuditEventBuilder::new()
523            .entity(EntityType::Task)
524            .entity_id("2.1")
525            .scope("change-1")
526            .op(ops::TASK_ADD)
527            .to("pending")
528            .actor(Actor::Cli)
529            .by("@test")
530            .meta(meta.clone())
531            .ctx(test_ctx())
532            .build()
533            .expect("should build");
534
535        assert_eq!(event.meta, Some(meta));
536    }
537
538    #[test]
539    fn schema_version_is_one() {
540        assert_eq!(SCHEMA_VERSION, 1);
541    }
542
543    #[test]
544    fn event_context_round_trip() {
545        let ctx = EventContext {
546            session_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(),
547            harness_session_id: Some("ses_abc123".to_string()),
548            branch: Some("feat/audit-log".to_string()),
549            worktree: Some("audit-log".to_string()),
550            commit: Some("3a7f2b1c".to_string()),
551        };
552
553        let json = serde_json::to_string(&ctx).expect("serialize");
554        let parsed: EventContext = serde_json::from_str(&json).expect("deserialize");
555        assert_eq!(ctx, parsed);
556    }
557
558    #[test]
559    fn entity_type_display() {
560        assert_eq!(EntityType::Task.to_string(), "task");
561        assert_eq!(EntityType::Planning.to_string(), "planning");
562    }
563
564    #[test]
565    fn entity_type_as_str_matches_serde() {
566        let variants = [
567            EntityType::Task,
568            EntityType::Change,
569            EntityType::Module,
570            EntityType::Wave,
571            EntityType::Planning,
572            EntityType::Config,
573        ];
574        for variant in variants {
575            let serde_str = serde_json::to_string(&variant)
576                .expect("serialize")
577                .trim_matches('"')
578                .to_string();
579            assert_eq!(variant.as_str(), serde_str);
580        }
581    }
582}