1use serde::{Deserialize, Serialize};
8
9pub const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct AuditEvent {
18 pub v: u32,
20 pub ts: String,
22 pub entity: String,
24 pub entity_id: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub scope: Option<String>,
29 pub op: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub from: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub to: Option<String>,
37 pub actor: String,
39 pub by: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub meta: Option<serde_json::Value>,
44 #[serde(default = "default_count", skip_serializing_if = "is_default_count")]
46 pub count: u64,
47 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum EntityType {
63 Task,
65 Change,
67 Module,
69 Wave,
71 Planning,
73 Config,
75}
76
77impl EntityType {
78 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum Actor {
101 Cli,
103 Reconcile,
105 Ralph,
107}
108
109impl Actor {
110 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct EventContext {
129 pub session_id: String,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub harness_session_id: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub branch: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub worktree: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub commit: Option<String>,
143}
144
145#[derive(Debug, Clone, PartialEq)]
147pub struct WorktreeInfo {
148 pub path: std::path::PathBuf,
150 pub branch: Option<String>,
152 pub is_main: bool,
154}
155
156#[derive(Debug, Clone)]
158pub struct TaggedAuditEvent {
159 pub event: AuditEvent,
161 pub source: WorktreeInfo,
163}
164
165pub mod ops {
170 pub const TASK_CREATE: &str = "create";
173 pub const TASK_STATUS_CHANGE: &str = "status_change";
175 pub const TASK_ADD: &str = "add";
177
178 pub const CHANGE_CREATE: &str = "create";
181 pub const CHANGE_ARCHIVE: &str = "archive";
183
184 pub const MODULE_CREATE: &str = "create";
187 pub const MODULE_CHANGE_ADDED: &str = "change_added";
189 pub const MODULE_CHANGE_COMPLETED: &str = "change_completed";
191
192 pub const WAVE_UNLOCK: &str = "unlock";
195
196 pub const PLANNING_DECISION: &str = "decision";
199 pub const PLANNING_BLOCKER: &str = "blocker";
201 pub const PLANNING_QUESTION: &str = "question";
203 pub const PLANNING_NOTE: &str = "note";
205 pub const PLANNING_FOCUS_CHANGE: &str = "focus_change";
207
208 pub const CONFIG_SET: &str = "set";
211 pub const CONFIG_UNSET: &str = "unset";
213
214 pub const RECONCILED: &str = "reconciled";
217}
218
219pub 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 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 pub fn entity(mut self, entity: EntityType) -> Self {
255 self.entity = Some(entity);
256 self
257 }
258
259 pub fn entity_id(mut self, id: impl Into<String>) -> Self {
261 self.entity_id = Some(id.into());
262 self
263 }
264
265 pub fn scope(mut self, scope: impl Into<String>) -> Self {
267 self.scope = Some(scope.into());
268 self
269 }
270
271 pub fn op(mut self, op: impl Into<String>) -> Self {
273 self.op = Some(op.into());
274 self
275 }
276
277 pub fn from(mut self, from: impl Into<String>) -> Self {
279 self.from = Some(from.into());
280 self
281 }
282
283 pub fn to(mut self, to: impl Into<String>) -> Self {
285 self.to = Some(to.into());
286 self
287 }
288
289 pub fn actor(mut self, actor: Actor) -> Self {
291 self.actor = Some(actor);
292 self
293 }
294
295 pub fn by(mut self, by: impl Into<String>) -> Self {
297 self.by = Some(by.into());
298 self
299 }
300
301 pub fn meta(mut self, meta: serde_json::Value) -> Self {
303 self.meta = Some(meta);
304 self
305 }
306
307 pub fn ctx(mut self, ctx: EventContext) -> Self {
309 self.ctx = Some(ctx);
310 self
311 }
312
313 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 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}