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)]
354mod tests {
355    use super::*;
356
357    fn test_ctx() -> EventContext {
358        EventContext {
359            session_id: "test-session-id".to_string(),
360            harness_session_id: None,
361            branch: Some("main".to_string()),
362            worktree: None,
363            commit: Some("abc12345".to_string()),
364        }
365    }
366
367    #[test]
368    fn audit_event_round_trip_serialization() {
369        let event = AuditEvent {
370            v: 1,
371            ts: "2026-02-08T14:30:00.000Z".to_string(),
372            entity: "task".to_string(),
373            entity_id: "2.1".to_string(),
374            scope: Some("009-02_audit-log".to_string()),
375            op: "status_change".to_string(),
376            from: Some("pending".to_string()),
377            to: Some("in-progress".to_string()),
378            actor: "cli".to_string(),
379            by: "@jack".to_string(),
380            meta: None,
381            count: 1,
382            ctx: test_ctx(),
383        };
384
385        let json = serde_json::to_string(&event).expect("serialize");
386        let parsed: AuditEvent = serde_json::from_str(&json).expect("deserialize");
387        assert_eq!(event, parsed);
388    }
389
390    #[test]
391    fn audit_event_serializes_to_single_line() {
392        let event = AuditEvent {
393            v: 1,
394            ts: "2026-02-08T14:30:00.000Z".to_string(),
395            entity: "task".to_string(),
396            entity_id: "1.1".to_string(),
397            scope: Some("test-change".to_string()),
398            op: "create".to_string(),
399            from: None,
400            to: Some("pending".to_string()),
401            actor: "cli".to_string(),
402            by: "@test".to_string(),
403            meta: None,
404            count: 1,
405            ctx: test_ctx(),
406        };
407
408        let json = serde_json::to_string(&event).expect("serialize");
409        assert!(!json.contains('\n'));
410    }
411
412    #[test]
413    fn optional_fields_omitted_when_none() {
414        let event = AuditEvent {
415            v: 1,
416            ts: "2026-02-08T14:30:00.000Z".to_string(),
417            entity: "change".to_string(),
418            entity_id: "test".to_string(),
419            scope: None,
420            op: "create".to_string(),
421            from: None,
422            to: None,
423            actor: "cli".to_string(),
424            by: "@test".to_string(),
425            meta: None,
426            count: 1,
427            ctx: EventContext {
428                session_id: "sid".to_string(),
429                harness_session_id: None,
430                branch: None,
431                worktree: None,
432                commit: None,
433            },
434        };
435
436        let json = serde_json::to_string(&event).expect("serialize");
437        assert!(!json.contains("scope"));
438        assert!(!json.contains("from"));
439        assert!(!json.contains("\"to\""));
440        assert!(!json.contains("meta"));
441        assert!(!json.contains("count"));
442        assert!(!json.contains("harness_session_id"));
443        assert!(!json.contains("branch"));
444        assert!(!json.contains("worktree"));
445        assert!(!json.contains("commit"));
446    }
447
448    #[test]
449    fn missing_count_deserializes_as_one() {
450        let json = r#"{
451            "v": 1,
452            "ts": "2026-02-08T14:30:00.000Z",
453            "entity": "task",
454            "entity_id": "1.1",
455            "op": "create",
456            "actor": "cli",
457            "by": "@test",
458            "ctx": { "session_id": "sid" }
459        }"#;
460
461        let event: AuditEvent = serde_json::from_str(json).expect("deserialize");
462        assert_eq!(event.count, 1);
463    }
464
465    #[test]
466    fn count_serializes_when_greater_than_one() {
467        let event = AuditEvent {
468            v: 1,
469            ts: "2026-02-08T14:30:00.000Z".to_string(),
470            entity: "task".to_string(),
471            entity_id: "1.1".to_string(),
472            scope: None,
473            op: "reconciled".to_string(),
474            from: None,
475            to: None,
476            actor: "reconcile".to_string(),
477            by: "@reconcile".to_string(),
478            meta: None,
479            count: 2,
480            ctx: EventContext {
481                session_id: "sid".to_string(),
482                harness_session_id: None,
483                branch: None,
484                worktree: None,
485                commit: None,
486            },
487        };
488
489        let json = serde_json::to_string(&event).expect("serialize");
490        assert!(json.contains("\"count\":2"));
491    }
492
493    #[test]
494    fn entity_type_serializes_to_lowercase() {
495        let json = serde_json::to_string(&EntityType::Task).expect("serialize");
496        assert_eq!(json, "\"task\"");
497
498        let json = serde_json::to_string(&EntityType::Config).expect("serialize");
499        assert_eq!(json, "\"config\"");
500    }
501
502    #[test]
503    fn entity_type_round_trip() {
504        let variants = [
505            EntityType::Task,
506            EntityType::Change,
507            EntityType::Module,
508            EntityType::Wave,
509            EntityType::Planning,
510            EntityType::Config,
511        ];
512        for variant in variants {
513            let json = serde_json::to_string(&variant).expect("serialize");
514            let parsed: EntityType = serde_json::from_str(&json).expect("deserialize");
515            assert_eq!(variant, parsed);
516        }
517    }
518
519    #[test]
520    fn actor_serializes_to_lowercase() {
521        assert_eq!(Actor::Cli.as_str(), "cli");
522        assert_eq!(Actor::Reconcile.as_str(), "reconcile");
523        assert_eq!(Actor::Ralph.as_str(), "ralph");
524    }
525
526    #[test]
527    fn actor_round_trip() {
528        let variants = [Actor::Cli, Actor::Reconcile, Actor::Ralph];
529        for variant in variants {
530            let json = serde_json::to_string(&variant).expect("serialize");
531            let parsed: Actor = serde_json::from_str(&json).expect("deserialize");
532            assert_eq!(variant, parsed);
533        }
534    }
535
536    #[test]
537    fn builder_produces_valid_event() {
538        let event = AuditEventBuilder::new()
539            .entity(EntityType::Task)
540            .entity_id("1.1")
541            .scope("test-change")
542            .op(ops::TASK_STATUS_CHANGE)
543            .from("pending")
544            .to("in-progress")
545            .actor(Actor::Cli)
546            .by("@jack")
547            .ctx(test_ctx())
548            .build()
549            .expect("should build");
550
551        assert_eq!(event.v, SCHEMA_VERSION);
552        assert_eq!(event.entity, "task");
553        assert_eq!(event.entity_id, "1.1");
554        assert_eq!(event.scope, Some("test-change".to_string()));
555        assert_eq!(event.op, "status_change");
556        assert_eq!(event.from, Some("pending".to_string()));
557        assert_eq!(event.to, Some("in-progress".to_string()));
558        assert_eq!(event.actor, "cli");
559        assert_eq!(event.by, "@jack");
560        assert!(!event.ts.is_empty());
561    }
562
563    #[test]
564    fn builder_returns_none_without_required_fields() {
565        assert!(AuditEventBuilder::new().build().is_none());
566
567        // Missing entity_id
568        assert!(
569            AuditEventBuilder::new()
570                .entity(EntityType::Task)
571                .op("create")
572                .actor(Actor::Cli)
573                .by("@test")
574                .ctx(test_ctx())
575                .build()
576                .is_none()
577        );
578    }
579
580    #[test]
581    fn builder_with_meta() {
582        let meta = serde_json::json!({"wave": 2, "name": "Test task"});
583        let event = AuditEventBuilder::new()
584            .entity(EntityType::Task)
585            .entity_id("2.1")
586            .scope("change-1")
587            .op(ops::TASK_ADD)
588            .to("pending")
589            .actor(Actor::Cli)
590            .by("@test")
591            .meta(meta.clone())
592            .ctx(test_ctx())
593            .build()
594            .expect("should build");
595
596        assert_eq!(event.meta, Some(meta));
597    }
598
599    #[test]
600    fn schema_version_is_one() {
601        assert_eq!(SCHEMA_VERSION, 1);
602    }
603
604    #[test]
605    fn event_context_round_trip() {
606        let ctx = EventContext {
607            session_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(),
608            harness_session_id: Some("ses_abc123".to_string()),
609            branch: Some("feat/audit-log".to_string()),
610            worktree: Some("audit-log".to_string()),
611            commit: Some("3a7f2b1c".to_string()),
612        };
613
614        let json = serde_json::to_string(&ctx).expect("serialize");
615        let parsed: EventContext = serde_json::from_str(&json).expect("deserialize");
616        assert_eq!(ctx, parsed);
617    }
618
619    #[test]
620    fn entity_type_display() {
621        assert_eq!(EntityType::Task.to_string(), "task");
622        assert_eq!(EntityType::Planning.to_string(), "planning");
623    }
624
625    #[test]
626    fn entity_type_as_str_matches_serde() {
627        let variants = [
628            EntityType::Task,
629            EntityType::Change,
630            EntityType::Module,
631            EntityType::Wave,
632            EntityType::Planning,
633            EntityType::Config,
634        ];
635        for variant in variants {
636            let serde_str = serde_json::to_string(&variant)
637                .expect("serialize")
638                .trim_matches('"')
639                .to_string();
640            assert_eq!(variant.as_str(), serde_str);
641        }
642    }
643}