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 pub ctx: EventContext,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum EntityType {
52 Task,
54 Change,
56 Module,
58 Wave,
60 Planning,
62 Config,
64}
65
66impl EntityType {
67 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum Actor {
90 Cli,
92 Reconcile,
94 Ralph,
96}
97
98impl Actor {
99 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub struct EventContext {
118 pub session_id: String,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub harness_session_id: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub branch: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub worktree: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub commit: Option<String>,
132}
133
134#[derive(Debug, Clone, PartialEq)]
136pub struct WorktreeInfo {
137 pub path: std::path::PathBuf,
139 pub branch: Option<String>,
141 pub is_main: bool,
143}
144
145#[derive(Debug, Clone)]
147pub struct TaggedAuditEvent {
148 pub event: AuditEvent,
150 pub source: WorktreeInfo,
152}
153
154pub mod ops {
159 pub const TASK_CREATE: &str = "create";
162 pub const TASK_STATUS_CHANGE: &str = "status_change";
164 pub const TASK_ADD: &str = "add";
166
167 pub const CHANGE_CREATE: &str = "create";
170 pub const CHANGE_ARCHIVE: &str = "archive";
172
173 pub const MODULE_CREATE: &str = "create";
176 pub const MODULE_CHANGE_ADDED: &str = "change_added";
178 pub const MODULE_CHANGE_COMPLETED: &str = "change_completed";
180
181 pub const WAVE_UNLOCK: &str = "unlock";
184
185 pub const PLANNING_DECISION: &str = "decision";
188 pub const PLANNING_BLOCKER: &str = "blocker";
190 pub const PLANNING_QUESTION: &str = "question";
192 pub const PLANNING_NOTE: &str = "note";
194 pub const PLANNING_FOCUS_CHANGE: &str = "focus_change";
196
197 pub const CONFIG_SET: &str = "set";
200 pub const CONFIG_UNSET: &str = "unset";
202
203 pub const RECONCILED: &str = "reconciled";
206}
207
208pub 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 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 pub fn entity(mut self, entity: EntityType) -> Self {
244 self.entity = Some(entity);
245 self
246 }
247
248 pub fn entity_id(mut self, id: impl Into<String>) -> Self {
250 self.entity_id = Some(id.into());
251 self
252 }
253
254 pub fn scope(mut self, scope: impl Into<String>) -> Self {
256 self.scope = Some(scope.into());
257 self
258 }
259
260 pub fn op(mut self, op: impl Into<String>) -> Self {
262 self.op = Some(op.into());
263 self
264 }
265
266 pub fn from(mut self, from: impl Into<String>) -> Self {
268 self.from = Some(from.into());
269 self
270 }
271
272 pub fn to(mut self, to: impl Into<String>) -> Self {
274 self.to = Some(to.into());
275 self
276 }
277
278 pub fn actor(mut self, actor: Actor) -> Self {
280 self.actor = Some(actor);
281 self
282 }
283
284 pub fn by(mut self, by: impl Into<String>) -> Self {
286 self.by = Some(by.into());
287 self
288 }
289
290 pub fn meta(mut self, meta: serde_json::Value) -> Self {
292 self.meta = Some(meta);
293 self
294 }
295
296 pub fn ctx(mut self, ctx: EventContext) -> Self {
298 self.ctx = Some(ctx);
299 self
300 }
301
302 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 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}