Skip to main content

edda_core/
event.rs

1use crate::canon::canonical_json_bytes;
2use crate::hash::sha256_hex;
3use crate::types::{
4    classify_event_type, DecisionPayload, Digest, Event, Refs, CANON_EDDA_V1, SCHEMA_VERSION,
5};
6
7/// Compute the hash for an event: serialize without the `hash` field,
8/// canonical JSON sort, then SHA-256.
9pub fn compute_event_hash(event_without_hash: &serde_json::Value) -> anyhow::Result<String> {
10    let bytes = canonical_json_bytes(event_without_hash)?;
11    Ok(sha256_hex(&bytes))
12}
13
14fn new_event_id() -> String {
15    format!("evt_{}", ulid::Ulid::new().to_string().to_lowercase())
16}
17
18fn now_rfc3339() -> String {
19    let now = time::OffsetDateTime::now_utc();
20    now.format(&time::format_description::well_known::Rfc3339)
21        .expect("RFC3339 formatting should not fail")
22}
23
24/// Set event_family and event_level based on event_type.
25fn set_taxonomy(event: &mut Event) {
26    let (family, level) = classify_event_type(&event.event_type);
27    event.event_family = family.map(|s| s.to_string());
28    event.event_level = level.map(|s| s.to_string());
29}
30
31fn finalize(event: &mut Event) -> anyhow::Result<()> {
32    // Auto-populate taxonomy fields before hashing
33    set_taxonomy(event);
34
35    // Serialize the event, remove hash/digests/schema_version, compute canonical hash
36    let mut val = serde_json::to_value(&*event)?;
37    if let Some(obj) = val.as_object_mut() {
38        obj.remove("hash");
39        obj.remove("digests");
40        obj.remove("schema_version");
41    }
42    let hash_value = compute_event_hash(&val)?;
43
44    event.hash = hash_value.clone();
45    event.digests = vec![Digest {
46        alg: "sha256".to_string(),
47        canon: CANON_EDDA_V1.to_string(),
48        value: hash_value,
49    }];
50    Ok(())
51}
52
53/// Re-finalize an event after post-construction modification.
54/// Recomputes hash and digests based on current event state.
55pub fn finalize_event(event: &mut Event) -> anyhow::Result<()> {
56    finalize(event)
57}
58
59/// Create a new `note` event.
60pub fn new_note_event(
61    branch: &str,
62    parent_hash: Option<&str>,
63    role: &str,
64    text: &str,
65    tags: &[String],
66) -> anyhow::Result<Event> {
67    let payload = serde_json::json!({
68        "role": role,
69        "text": text,
70        "tags": tags,
71    });
72
73    let mut event = Event {
74        event_id: new_event_id(),
75        ts: now_rfc3339(),
76        event_type: "note".to_string(),
77        branch: branch.to_string(),
78        parent_hash: parent_hash.map(|s| s.to_string()),
79        hash: String::new(),
80        payload,
81        refs: Refs::default(),
82        schema_version: SCHEMA_VERSION,
83        digests: Vec::new(),
84        event_family: None,
85        event_level: None,
86    };
87
88    finalize(&mut event)?;
89    Ok(event)
90}
91
92/// Create a new `decision` event (a note event with structured decision payload).
93pub fn new_decision_event(
94    branch: &str,
95    parent_hash: Option<&str>,
96    role: &str,
97    decision: &DecisionPayload,
98) -> anyhow::Result<Event> {
99    let text = match &decision.reason {
100        Some(r) => format!("{}: {} \u{2014} {}", decision.key, decision.value, r),
101        None => format!("{}: {}", decision.key, decision.value),
102    };
103    let tags = vec!["decision".to_string()];
104    let mut event = new_note_event(branch, parent_hash, role, &text, &tags)?;
105    event.payload["decision"] = serde_json::to_value(decision)?;
106    finalize(&mut event)?;
107    Ok(event)
108}
109
110/// Parameters for creating a `cmd` event.
111pub struct CmdEventParams<'a> {
112    pub branch: &'a str,
113    pub parent_hash: Option<&'a str>,
114    pub argv: &'a [String],
115    pub cwd: &'a str,
116    pub exit_code: i32,
117    pub duration_ms: u64,
118    pub stdout_blob: &'a str,
119    pub stderr_blob: &'a str,
120}
121
122/// Create a new `cmd` event.
123pub fn new_cmd_event(params: &CmdEventParams<'_>) -> anyhow::Result<Event> {
124    let payload = serde_json::json!({
125        "argv": params.argv,
126        "cwd": params.cwd,
127        "exit_code": params.exit_code,
128        "duration_ms": params.duration_ms,
129        "stdout_blob": params.stdout_blob,
130        "stderr_blob": params.stderr_blob,
131    });
132
133    let mut blob_refs = Vec::new();
134    if !params.stdout_blob.is_empty() {
135        blob_refs.push(params.stdout_blob.to_string());
136    }
137    if !params.stderr_blob.is_empty() {
138        blob_refs.push(params.stderr_blob.to_string());
139    }
140
141    let mut event = Event {
142        event_id: new_event_id(),
143        ts: now_rfc3339(),
144        event_type: "cmd".to_string(),
145        branch: params.branch.to_string(),
146        parent_hash: params.parent_hash.map(|s| s.to_string()),
147        hash: String::new(),
148        payload,
149        refs: Refs {
150            blobs: blob_refs,
151            ..Default::default()
152        },
153        schema_version: SCHEMA_VERSION,
154        digests: Vec::new(),
155        event_family: None,
156        event_level: None,
157    };
158
159    finalize(&mut event)?;
160    Ok(event)
161}
162
163/// Parameters for creating a `commit` event.
164pub struct CommitEventParams<'a> {
165    pub branch: &'a str,
166    pub parent_hash: Option<&'a str>,
167    pub title: &'a str,
168    pub purpose: Option<&'a str>,
169    pub prev_summary: &'a str,
170    pub contribution: &'a str,
171    pub evidence: Vec<serde_json::Value>,
172    pub labels: Vec<String>,
173}
174
175/// Create a new `commit` event.
176/// If evidence is empty and labels don't contain "claim", auto-adds "claim" (EVIDENCE-01).
177pub fn new_commit_event(params: &mut CommitEventParams<'_>) -> anyhow::Result<Event> {
178    // EVIDENCE-01: no evidence → auto label "claim"
179    if params.evidence.is_empty() && !params.labels.iter().any(|l| l == "claim") {
180        params.labels.push("claim".to_string());
181    }
182
183    let payload = serde_json::json!({
184        "title": params.title,
185        "purpose": params.purpose.unwrap_or(""),
186        "prev_summary": params.prev_summary,
187        "contribution": params.contribution,
188        "evidence": params.evidence,
189        "labels": params.labels,
190    });
191
192    // Collect event refs from evidence
193    let mut event_refs = Vec::new();
194    for item in &params.evidence {
195        if let Some(eid) = item.get("event_id").and_then(|v| v.as_str()) {
196            event_refs.push(eid.to_string());
197        }
198    }
199
200    let mut event = Event {
201        event_id: new_event_id(),
202        ts: now_rfc3339(),
203        event_type: "commit".to_string(),
204        branch: params.branch.to_string(),
205        parent_hash: params.parent_hash.map(|s| s.to_string()),
206        hash: String::new(),
207        payload,
208        refs: Refs {
209            events: event_refs,
210            ..Default::default()
211        },
212        schema_version: SCHEMA_VERSION,
213        digests: Vec::new(),
214        event_family: None,
215        event_level: None,
216    };
217
218    finalize(&mut event)?;
219    Ok(event)
220}
221
222/// Create a new `rebuild` event.
223pub fn new_rebuild_event(
224    branch: &str,
225    parent_hash: Option<&str>,
226    scope: &str,
227    target_branch: Option<&str>,
228    reason: &str,
229) -> anyhow::Result<Event> {
230    let payload = serde_json::json!({
231        "scope": scope,
232        "branch": target_branch.unwrap_or(""),
233        "reason": reason,
234    });
235
236    let mut event = Event {
237        event_id: new_event_id(),
238        ts: now_rfc3339(),
239        event_type: "rebuild".to_string(),
240        branch: branch.to_string(),
241        parent_hash: parent_hash.map(|s| s.to_string()),
242        hash: String::new(),
243        payload,
244        refs: Refs::default(),
245        schema_version: SCHEMA_VERSION,
246        digests: Vec::new(),
247        event_family: None,
248        event_level: None,
249    };
250
251    finalize(&mut event)?;
252    Ok(event)
253}
254
255/// Create a new `branch_create` event.
256pub fn new_branch_create_event(
257    branch: &str,
258    parent_hash: Option<&str>,
259    name: &str,
260    purpose: &str,
261    from_branch: &str,
262    from_event_id: Option<&str>,
263) -> anyhow::Result<Event> {
264    let payload = serde_json::json!({
265        "name": name,
266        "purpose": purpose,
267        "from_branch": from_branch,
268        "from_event_id": from_event_id.unwrap_or(""),
269    });
270
271    let mut event = Event {
272        event_id: new_event_id(),
273        ts: now_rfc3339(),
274        event_type: "branch_create".to_string(),
275        branch: branch.to_string(),
276        parent_hash: parent_hash.map(|s| s.to_string()),
277        hash: String::new(),
278        payload,
279        refs: Refs::default(),
280        schema_version: SCHEMA_VERSION,
281        digests: Vec::new(),
282        event_family: None,
283        event_level: None,
284    };
285
286    finalize(&mut event)?;
287    Ok(event)
288}
289
290/// Create a new `branch_switch` event.
291pub fn new_branch_switch_event(
292    branch: &str,
293    parent_hash: Option<&str>,
294    from: &str,
295    to: &str,
296) -> anyhow::Result<Event> {
297    let payload = serde_json::json!({
298        "from": from,
299        "to": to,
300    });
301
302    let mut event = Event {
303        event_id: new_event_id(),
304        ts: now_rfc3339(),
305        event_type: "branch_switch".to_string(),
306        branch: branch.to_string(),
307        parent_hash: parent_hash.map(|s| s.to_string()),
308        hash: String::new(),
309        payload,
310        refs: Refs::default(),
311        schema_version: SCHEMA_VERSION,
312        digests: Vec::new(),
313        event_family: None,
314        event_level: None,
315    };
316
317    finalize(&mut event)?;
318    Ok(event)
319}
320
321/// Create a new `merge` event.
322pub fn new_merge_event(
323    branch: &str,
324    parent_hash: Option<&str>,
325    src: &str,
326    dst: &str,
327    reason: &str,
328    adopted_commits: &[String],
329) -> anyhow::Result<Event> {
330    let payload = serde_json::json!({
331        "src": src,
332        "dst": dst,
333        "reason": reason,
334        "adopted_commits": adopted_commits,
335    });
336
337    let mut event = Event {
338        event_id: new_event_id(),
339        ts: now_rfc3339(),
340        event_type: "merge".to_string(),
341        branch: branch.to_string(),
342        parent_hash: parent_hash.map(|s| s.to_string()),
343        hash: String::new(),
344        payload,
345        refs: Refs::default(),
346        schema_version: SCHEMA_VERSION,
347        digests: Vec::new(),
348        event_family: None,
349        event_level: None,
350    };
351
352    finalize(&mut event)?;
353    Ok(event)
354}
355
356/// Parameters for creating an approval event.
357pub struct ApprovalEventParams<'a> {
358    pub branch: &'a str,
359    pub parent_hash: Option<&'a str>,
360    pub draft_id: &'a str,
361    pub draft_sha256: &'a str,
362    pub decision: &'a str,
363    pub actor: &'a str,
364    pub note: &'a str,
365    pub stage_id: &'a str,
366    pub role: &'a str,
367    /// Optional device identifier for remote approval audit trail.
368    pub device_id: Option<&'a str>,
369}
370
371/// Create a new `approval` event.
372pub fn new_approval_event(p: &ApprovalEventParams<'_>) -> anyhow::Result<Event> {
373    let mut payload = serde_json::json!({
374        "draft_id": p.draft_id,
375        "draft_sha256": p.draft_sha256,
376        "decision": p.decision,
377        "actor": p.actor,
378        "note": p.note,
379        "stage_id": p.stage_id,
380        "role": p.role,
381    });
382    if let Some(device_id) = p.device_id {
383        payload["device_id"] = serde_json::Value::String(device_id.to_string());
384    }
385
386    let mut event = Event {
387        event_id: new_event_id(),
388        ts: now_rfc3339(),
389        event_type: "approval".to_string(),
390        branch: p.branch.to_string(),
391        parent_hash: p.parent_hash.map(|s| s.to_string()),
392        hash: String::new(),
393        payload,
394        refs: Refs::default(),
395        schema_version: SCHEMA_VERSION,
396        digests: Vec::new(),
397        event_family: None,
398        event_level: None,
399    };
400
401    finalize(&mut event)?;
402    Ok(event)
403}
404
405/// Parameters for creating an approval_request event.
406pub struct ApprovalRequestParams<'a> {
407    pub branch: &'a str,
408    pub parent_hash: Option<&'a str>,
409    pub draft_id: &'a str,
410    pub draft_sha256: &'a str,
411    pub route_rule_id: &'a str,
412    pub stage_id: &'a str,
413    pub role: &'a str,
414    pub assignees: &'a [String],
415    pub reason: &'a str,
416}
417
418/// Create a new `approval_request` event.
419pub fn new_approval_request_event(p: &ApprovalRequestParams<'_>) -> anyhow::Result<Event> {
420    let payload = serde_json::json!({
421        "draft_id": p.draft_id,
422        "draft_sha256": p.draft_sha256,
423        "route_rule_id": p.route_rule_id,
424        "stage_id": p.stage_id,
425        "role": p.role,
426        "assignees": p.assignees,
427        "reason": p.reason,
428    });
429
430    let mut event = Event {
431        event_id: new_event_id(),
432        ts: now_rfc3339(),
433        event_type: "approval_request".to_string(),
434        branch: p.branch.to_string(),
435        parent_hash: p.parent_hash.map(|s| s.to_string()),
436        hash: String::new(),
437        payload,
438        refs: Refs::default(),
439        schema_version: SCHEMA_VERSION,
440        digests: Vec::new(),
441        event_family: None,
442        event_level: None,
443    };
444
445    finalize(&mut event)?;
446    Ok(event)
447}
448
449/// Parameters for creating a `task_intake` event.
450pub struct TaskIntakeParams {
451    pub branch: String,
452    pub parent_hash: Option<String>,
453    pub source: String,
454    pub source_id: String,
455    pub source_url: String,
456    pub title: String,
457    pub intent: String,
458    pub labels: Vec<String>,
459    pub priority: String,
460    pub constraints: Vec<String>,
461}
462
463/// Create a new `task_intake` event.
464pub fn new_task_intake_event(params: &TaskIntakeParams) -> anyhow::Result<Event> {
465    let payload = serde_json::json!({
466        "source": params.source,
467        "source_id": params.source_id,
468        "source_url": params.source_url,
469        "title": params.title,
470        "intent": params.intent,
471        "labels": params.labels,
472        "priority": params.priority,
473        "constraints": params.constraints,
474    });
475
476    let mut event = Event {
477        event_id: new_event_id(),
478        ts: now_rfc3339(),
479        event_type: "task_intake".to_string(),
480        branch: params.branch.clone(),
481        parent_hash: params.parent_hash.clone(),
482        hash: String::new(),
483        payload,
484        refs: Refs::default(),
485        schema_version: SCHEMA_VERSION,
486        digests: Vec::new(),
487        event_family: None,
488        event_level: None,
489    };
490
491    finalize(&mut event)?;
492    Ok(event)
493}
494
495/// Parameters for creating an agent_phase_change event.
496pub struct AgentPhaseChangeParams<'a> {
497    pub branch: &'a str,
498    pub parent_hash: Option<&'a str>,
499    pub session_id: &'a str,
500    pub label: Option<&'a str>,
501    pub from: &'a str,
502    pub to: &'a str,
503    pub issue: Option<u64>,
504    pub confidence: f32,
505    pub signals: &'a [String],
506}
507
508/// Create a new `agent_phase_change` event.
509pub fn new_agent_phase_change_event(p: &AgentPhaseChangeParams<'_>) -> anyhow::Result<Event> {
510    let mut payload = serde_json::json!({
511        "session_id": p.session_id,
512        "from": p.from,
513        "to": p.to,
514        "confidence": p.confidence,
515        "signals": p.signals,
516    });
517    if let Some(label) = p.label {
518        payload["label"] = serde_json::json!(label);
519    }
520    if let Some(issue) = p.issue {
521        payload["issue"] = serde_json::json!(issue);
522    }
523
524    let mut event = Event {
525        event_id: new_event_id(),
526        ts: now_rfc3339(),
527        event_type: "agent_phase_change".to_string(),
528        branch: p.branch.to_string(),
529        parent_hash: p.parent_hash.map(|s| s.to_string()),
530        hash: String::new(),
531        payload,
532        refs: Refs::default(),
533        schema_version: SCHEMA_VERSION,
534        digests: Vec::new(),
535        event_family: None,
536        event_level: None,
537    };
538
539    finalize(&mut event)?;
540    Ok(event)
541}
542
543/// Parameters for creating a `review_bundle` event.
544pub struct ReviewBundleParams {
545    pub branch: String,
546    pub parent_hash: Option<String>,
547    pub bundle: crate::bundle::ReviewBundle,
548}
549
550/// Create a new `review_bundle` event.
551pub fn new_review_bundle_event(params: &ReviewBundleParams) -> anyhow::Result<Event> {
552    let payload = serde_json::to_value(&params.bundle)?;
553
554    let mut event = Event {
555        event_id: new_event_id(),
556        ts: now_rfc3339(),
557        event_type: "review_bundle".to_string(),
558        branch: params.branch.clone(),
559        parent_hash: params.parent_hash.clone(),
560        hash: String::new(),
561        payload,
562        refs: Refs::default(),
563        schema_version: SCHEMA_VERSION,
564        digests: Vec::new(),
565        event_family: None,
566        event_level: None,
567    };
568
569    finalize(&mut event)?;
570    Ok(event)
571}
572
573/// Parameters for creating a `pr` event.
574#[derive(Debug, Clone)]
575pub struct PrEventParams {
576    pub branch: String,
577    pub parent_hash: Option<String>,
578    pub pr_number: u64,
579    pub pr_status: String,
580    pub review_result: Option<String>,
581    pub blocker_count: u32,
582    pub time_to_merge_hours: Option<f64>,
583    pub created_at: String,
584    pub merged_at: Option<String>,
585    pub author: String,
586    pub title: String,
587}
588
589/// Create a new `pr` event.
590pub fn new_pr_event(params: &PrEventParams) -> anyhow::Result<Event> {
591    let payload = serde_json::json!({
592        "pr_number": params.pr_number,
593        "pr_status": params.pr_status,
594        "review_result": params.review_result,
595        "blocker_count": params.blocker_count,
596        "time_to_merge_hours": params.time_to_merge_hours,
597        "created_at": params.created_at,
598        "merged_at": params.merged_at,
599        "author": params.author,
600        "title": params.title,
601    });
602
603    let mut event = Event {
604        event_id: new_event_id(),
605        ts: now_rfc3339(),
606        event_type: "pr".to_string(),
607        branch: params.branch.clone(),
608        parent_hash: params.parent_hash.clone(),
609        hash: String::new(),
610        payload,
611        refs: Refs::default(),
612        schema_version: SCHEMA_VERSION,
613        digests: Vec::new(),
614        event_family: None,
615        event_level: None,
616    };
617
618    finalize(&mut event)?;
619    Ok(event)
620}
621
622/// Create a new `execution_event` for Karvi execution events.
623///
624/// Uses the caller-supplied `event_id` and `occurred_at` from the Karvi envelope
625/// rather than generating new ones. The full Karvi envelope is stored as `payload`.
626///
627/// If `decision_ref` is `Some`, a provenance link (`based_on`) is added to `refs`.
628pub fn new_execution_event(
629    branch: &str,
630    parent_hash: Option<&str>,
631    event_id_override: &str,
632    occurred_at: &str,
633    payload: serde_json::Value,
634    decision_ref: Option<&str>,
635) -> anyhow::Result<Event> {
636    use crate::types::{rel, Provenance};
637
638    let refs = if let Some(dec_ref) = decision_ref {
639        Refs {
640            provenance: vec![Provenance {
641                target: dec_ref.to_string(),
642                rel: rel::BASED_ON.to_string(),
643                note: Some("karvi decision_ref".to_string()),
644            }],
645            ..Default::default()
646        }
647    } else {
648        Refs::default()
649    };
650
651    let mut event = Event {
652        event_id: event_id_override.to_string(),
653        ts: occurred_at.to_string(),
654        event_type: "execution_event".to_string(),
655        branch: branch.to_string(),
656        parent_hash: parent_hash.map(|s| s.to_string()),
657        hash: String::new(),
658        payload,
659        refs,
660        schema_version: SCHEMA_VERSION,
661        digests: Vec::new(),
662        event_family: None,
663        event_level: None,
664    };
665
666    finalize(&mut event)?;
667    Ok(event)
668}
669
670/// Create a new `decide_snapshot` event.
671///
672/// The payload contains compact metadata; large context/result data should
673/// be offloaded to blob store by the caller and referenced via `blob_refs`.
674pub fn new_snapshot_event(
675    branch: &str,
676    parent_hash: Option<&str>,
677    payload: serde_json::Value,
678    blob_refs: Vec<String>,
679) -> anyhow::Result<Event> {
680    let mut event = Event {
681        event_id: new_event_id(),
682        ts: now_rfc3339(),
683        event_type: "decide_snapshot".to_string(),
684        branch: branch.to_string(),
685        parent_hash: parent_hash.map(|s| s.to_string()),
686        hash: String::new(),
687        payload,
688        refs: Refs {
689            blobs: blob_refs,
690            ..Default::default()
691        },
692        schema_version: SCHEMA_VERSION,
693        digests: Vec::new(),
694        event_family: None,
695        event_level: None,
696    };
697
698    finalize(&mut event)?;
699    Ok(event)
700}
701
702/// Create a new `cycle_telemetry` event for governance cycle telemetry.
703///
704/// Uses the caller-supplied `cycle_id` as the event_id for idempotency
705/// and `started_at` as the timestamp. The full telemetry body is stored
706/// as `payload`.
707pub fn new_telemetry_event(
708    branch: &str,
709    parent_hash: Option<&str>,
710    cycle_id: &str,
711    started_at: &str,
712    payload: serde_json::Value,
713) -> anyhow::Result<Event> {
714    let mut event = Event {
715        event_id: cycle_id.to_string(),
716        ts: started_at.to_string(),
717        event_type: "cycle_telemetry".to_string(),
718        branch: branch.to_string(),
719        parent_hash: parent_hash.map(|s| s.to_string()),
720        hash: String::new(),
721        payload,
722        refs: Refs::default(),
723        schema_version: SCHEMA_VERSION,
724        digests: Vec::new(),
725        event_family: None,
726        event_level: None,
727    };
728
729    finalize(&mut event)?;
730    Ok(event)
731}
732
733/// Parameters for creating an `approval_policy_match` event.
734#[derive(Debug, Clone)]
735pub struct ApprovalPolicyMatchParams {
736    pub branch: String,
737    pub parent_hash: Option<String>,
738    pub task_id: String,
739    pub step: String,
740    pub matched_rule: Option<String>,
741    pub action: String,
742    pub reason: String,
743    pub risk_level: Option<String>,
744    pub files_changed: Option<u32>,
745}
746
747/// Create a new `approval_policy_match` event.
748pub fn new_approval_policy_match_event(
749    params: &ApprovalPolicyMatchParams,
750) -> anyhow::Result<Event> {
751    let mut payload = serde_json::json!({
752        "task_id": params.task_id,
753        "step": params.step,
754        "action": params.action,
755        "reason": params.reason,
756    });
757
758    if let Some(rule) = &params.matched_rule {
759        payload["matched_rule"] = serde_json::json!(rule);
760    }
761    if let Some(risk) = &params.risk_level {
762        payload["risk_level"] = serde_json::json!(risk);
763    }
764    if let Some(fc) = params.files_changed {
765        payload["files_changed"] = serde_json::json!(fc);
766    }
767
768    let mut event = Event {
769        event_id: new_event_id(),
770        ts: now_rfc3339(),
771        event_type: "approval_policy_match".to_string(),
772        branch: params.branch.clone(),
773        parent_hash: params.parent_hash.clone(),
774        hash: String::new(),
775        payload,
776        refs: Refs::default(),
777        schema_version: SCHEMA_VERSION,
778        digests: Vec::new(),
779        event_family: None,
780        event_level: None,
781    };
782
783    finalize(&mut event)?;
784    Ok(event)
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    #[test]
792    fn note_event_has_valid_id_and_hash() {
793        let event = new_note_event("main", None, "user", "hello", &[]).unwrap();
794        assert!(event.event_id.starts_with("evt_"));
795        assert_eq!(event.hash.len(), 64);
796        assert!(event.hash.chars().all(|c| c.is_ascii_hexdigit()));
797        assert_eq!(event.event_type, "note");
798        assert!(event.parent_hash.is_none());
799        assert_eq!(event.schema_version, SCHEMA_VERSION);
800        assert_eq!(event.digests.len(), 1);
801        assert_eq!(event.digests[0].alg, "sha256");
802        assert_eq!(event.digests[0].canon, CANON_EDDA_V1);
803        assert_eq!(event.digests[0].value, event.hash);
804    }
805
806    #[test]
807    fn decision_event_has_structured_payload() {
808        let dp = DecisionPayload {
809            key: "db.engine".to_string(),
810            value: "sqlite".to_string(),
811            reason: Some("embedded, zero-config".to_string()),
812            scope: None,
813            authority: None,
814            affected_paths: None,
815            tags: None,
816            review_after: None,
817            reversibility: None,
818            village_id: None,
819        };
820        let event = new_decision_event("main", None, "system", &dp).unwrap();
821        assert_eq!(event.event_type, "note");
822        assert_eq!(event.payload["decision"]["key"], "db.engine");
823        assert_eq!(event.payload["decision"]["value"], "sqlite");
824        assert_eq!(event.payload["decision"]["reason"], "embedded, zero-config");
825        assert_eq!(
826            event.payload["text"],
827            "db.engine: sqlite \u{2014} embedded, zero-config"
828        );
829        let tags = event.payload["tags"].as_array().unwrap();
830        assert!(tags.iter().any(|t| t.as_str() == Some("decision")));
831        assert_eq!(event.schema_version, SCHEMA_VERSION);
832        assert_eq!(event.digests[0].value, event.hash);
833    }
834
835    #[test]
836    fn decision_event_without_reason() {
837        let dp = DecisionPayload {
838            key: "auth.method".to_string(),
839            value: "JWT".to_string(),
840            reason: None,
841            scope: None,
842            authority: None,
843            affected_paths: None,
844            tags: None,
845            review_after: None,
846            reversibility: None,
847            village_id: None,
848        };
849        let event = new_decision_event("main", None, "system", &dp).unwrap();
850        assert_eq!(event.payload["decision"]["key"], "auth.method");
851        assert_eq!(event.payload["decision"]["value"], "JWT");
852        assert!(event.payload["decision"].get("reason").is_none());
853        assert_eq!(event.payload["text"], "auth.method: JWT");
854    }
855
856    #[test]
857    fn decision_event_extractable() {
858        let dp = DecisionPayload {
859            key: "api.style".to_string(),
860            value: "REST".to_string(),
861            reason: Some("compatibility".to_string()),
862            scope: None,
863            authority: None,
864            affected_paths: None,
865            tags: None,
866            review_after: None,
867            reversibility: None,
868            village_id: None,
869        };
870        let event = new_decision_event("main", None, "system", &dp).unwrap();
871        let extracted = crate::decision::extract_decision(&event.payload).unwrap();
872        assert_eq!(extracted, dp);
873    }
874
875    #[test]
876    fn note_event_parent_hash_propagates() {
877        let e1 = new_note_event("main", None, "user", "first", &[]).unwrap();
878        let e2 = new_note_event("main", Some(&e1.hash), "user", "second", &[]).unwrap();
879        assert_eq!(e2.parent_hash.as_deref(), Some(e1.hash.as_str()));
880    }
881
882    #[test]
883    fn cmd_event_has_blob_refs() {
884        let argv = vec!["echo".to_string(), "hi".to_string()];
885        let event = new_cmd_event(&CmdEventParams {
886            branch: "main",
887            parent_hash: None,
888            argv: &argv,
889            cwd: ".",
890            exit_code: 0,
891            duration_ms: 100,
892            stdout_blob: "blob:sha256:aaa",
893            stderr_blob: "blob:sha256:bbb",
894        })
895        .unwrap();
896        assert_eq!(event.event_type, "cmd");
897        assert_eq!(event.refs.blobs.len(), 2);
898        assert_eq!(event.refs.blobs[0], "blob:sha256:aaa");
899        assert!(event.refs.provenance.is_empty());
900        assert_eq!(event.schema_version, SCHEMA_VERSION);
901        assert_eq!(event.digests.len(), 1);
902        assert_eq!(event.digests[0].value, event.hash);
903    }
904
905    #[test]
906    fn hash_is_deterministic_for_same_content() {
907        // Two events with same content but different event_id/ts will have different hashes.
908        // But recomputing hash for the same event should yield the same result.
909        let event = new_note_event("main", None, "user", "test", &[]).unwrap();
910        let mut val = serde_json::to_value(&event).unwrap();
911        let obj = val.as_object_mut().unwrap();
912        obj.remove("hash");
913        obj.remove("digests");
914        obj.remove("schema_version");
915        let recomputed = compute_event_hash(&val).unwrap();
916        assert_eq!(recomputed, event.hash);
917    }
918
919    #[test]
920    fn commit_event_auto_claim_when_no_evidence() {
921        let event = new_commit_event(&mut CommitEventParams {
922            branch: "main",
923            parent_hash: None,
924            title: "test commit",
925            purpose: None,
926            prev_summary: "",
927            contribution: "did something",
928            evidence: vec![],
929            labels: vec![],
930        })
931        .unwrap();
932        assert_eq!(event.event_type, "commit");
933        let labels = event.payload["labels"].as_array().unwrap();
934        assert!(labels.iter().any(|l| l.as_str() == Some("claim")));
935        assert_eq!(event.schema_version, SCHEMA_VERSION);
936        assert_eq!(event.digests[0].value, event.hash);
937    }
938
939    #[test]
940    fn commit_event_no_auto_claim_with_evidence() {
941        let evidence = vec![serde_json::json!({"event_id": "evt_test", "why": "passed"})];
942        let event = new_commit_event(&mut CommitEventParams {
943            branch: "main",
944            parent_hash: None,
945            title: "verified commit",
946            purpose: Some("testing"),
947            prev_summary: "prev",
948            contribution: "this",
949            evidence,
950            labels: vec!["safe".to_string()],
951        })
952        .unwrap();
953        let labels = event.payload["labels"].as_array().unwrap();
954        assert!(!labels.iter().any(|l| l.as_str() == Some("claim")));
955        assert!(labels.iter().any(|l| l.as_str() == Some("safe")));
956        assert_eq!(event.refs.events, vec!["evt_test"]);
957        assert!(event.refs.provenance.is_empty());
958        assert_eq!(event.schema_version, SCHEMA_VERSION);
959        assert_eq!(event.digests[0].value, event.hash);
960    }
961
962    #[test]
963    fn rebuild_event_fields() {
964        let event = new_rebuild_event("main", None, "all", None, "rebuild views").unwrap();
965        assert_eq!(event.event_type, "rebuild");
966        assert_eq!(event.payload["scope"], "all");
967        assert_eq!(event.payload["reason"], "rebuild views");
968        assert_eq!(event.schema_version, SCHEMA_VERSION);
969        assert_eq!(event.digests[0].value, event.hash);
970    }
971
972    #[test]
973    fn branch_create_event_fields() {
974        let event = new_branch_create_event(
975            "main",
976            None,
977            "feat/x",
978            "try alternative",
979            "main",
980            Some("evt_test"),
981        )
982        .unwrap();
983        assert_eq!(event.event_type, "branch_create");
984        assert_eq!(event.payload["name"], "feat/x");
985        assert_eq!(event.payload["purpose"], "try alternative");
986        assert_eq!(event.payload["from_branch"], "main");
987        assert_eq!(event.payload["from_event_id"], "evt_test");
988        assert_eq!(event.schema_version, SCHEMA_VERSION);
989        assert_eq!(event.digests[0].value, event.hash);
990    }
991
992    #[test]
993    fn branch_switch_event_fields() {
994        let event = new_branch_switch_event("feat/x", None, "main", "feat/x").unwrap();
995        assert_eq!(event.event_type, "branch_switch");
996        assert_eq!(event.payload["from"], "main");
997        assert_eq!(event.payload["to"], "feat/x");
998        assert_eq!(event.schema_version, SCHEMA_VERSION);
999        assert_eq!(event.digests[0].value, event.hash);
1000    }
1001
1002    #[test]
1003    fn merge_event_fields() {
1004        let adopted = vec!["evt_a".to_string(), "evt_b".to_string()];
1005        let event =
1006            new_merge_event("main", None, "feat/x", "main", "accept feature", &adopted).unwrap();
1007        assert_eq!(event.event_type, "merge");
1008        assert_eq!(event.payload["src"], "feat/x");
1009        assert_eq!(event.payload["dst"], "main");
1010        assert_eq!(event.payload["reason"], "accept feature");
1011        let ac = event.payload["adopted_commits"].as_array().unwrap();
1012        assert_eq!(ac.len(), 2);
1013        assert_eq!(ac[0], "evt_a");
1014        assert_eq!(event.schema_version, SCHEMA_VERSION);
1015        assert_eq!(event.digests[0].value, event.hash);
1016    }
1017
1018    #[test]
1019    fn approval_event_fields() {
1020        let event = new_approval_event(&ApprovalEventParams {
1021            branch: "main",
1022            parent_hash: None,
1023            draft_id: "drf_test123",
1024            draft_sha256: "sha256abc",
1025            decision: "approve",
1026            actor: "alice",
1027            note: "LGTM",
1028            stage_id: "lead",
1029            role: "lead",
1030            device_id: None,
1031        })
1032        .unwrap();
1033        assert_eq!(event.event_type, "approval");
1034        assert_eq!(event.payload["draft_id"], "drf_test123");
1035        assert_eq!(event.payload["decision"], "approve");
1036        assert_eq!(event.payload["actor"], "alice");
1037        assert_eq!(event.payload["stage_id"], "lead");
1038        assert_eq!(event.payload["role"], "lead");
1039        assert_eq!(event.schema_version, SCHEMA_VERSION);
1040        assert_eq!(event.digests[0].value, event.hash);
1041        // device_id should not be present when None
1042        assert!(event.payload.get("device_id").is_none());
1043    }
1044
1045    #[test]
1046    fn approval_event_with_device_id() {
1047        let event = new_approval_event(&ApprovalEventParams {
1048            branch: "main",
1049            parent_hash: None,
1050            draft_id: "drf_dev",
1051            draft_sha256: "abc123",
1052            decision: "approve",
1053            actor: "alice",
1054            note: "approved from phone",
1055            stage_id: "lead",
1056            role: "lead",
1057            device_id: Some("iphone-14-abc"),
1058        })
1059        .unwrap();
1060        assert_eq!(event.payload["device_id"], "iphone-14-abc");
1061        assert_eq!(event.payload["actor"], "alice");
1062    }
1063
1064    #[test]
1065    fn approval_request_event_fields() {
1066        let assignees = vec!["alice".to_string(), "bob".to_string()];
1067        let event = new_approval_request_event(&ApprovalRequestParams {
1068            branch: "main",
1069            parent_hash: None,
1070            draft_id: "drf_test456",
1071            draft_sha256: "sha256def",
1072            route_rule_id: "risky",
1073            stage_id: "lead",
1074            role: "lead",
1075            assignees: &assignees,
1076            reason: "matched rule risky",
1077        })
1078        .unwrap();
1079        assert_eq!(event.event_type, "approval_request");
1080        assert_eq!(event.payload["draft_id"], "drf_test456");
1081        assert_eq!(event.payload["route_rule_id"], "risky");
1082        assert_eq!(event.payload["stage_id"], "lead");
1083        assert_eq!(event.payload["role"], "lead");
1084        let a = event.payload["assignees"].as_array().unwrap();
1085        assert_eq!(a.len(), 2);
1086        assert_eq!(event.schema_version, SCHEMA_VERSION);
1087        assert_eq!(event.digests[0].value, event.hash);
1088    }
1089
1090    #[test]
1091    fn event_round_trip_serialize() {
1092        let event = new_note_event("main", None, "user", "test", &["todo".to_string()]).unwrap();
1093        let json = serde_json::to_string(&event).unwrap();
1094        let deserialized: Event = serde_json::from_str(&json).unwrap();
1095        assert_eq!(deserialized.event_id, event.event_id);
1096        assert_eq!(deserialized.hash, event.hash);
1097        assert_eq!(deserialized.event_type, event.event_type);
1098        assert_eq!(deserialized.branch, event.branch);
1099        assert_eq!(deserialized.parent_hash, event.parent_hash);
1100        assert_eq!(deserialized.schema_version, event.schema_version);
1101        assert_eq!(deserialized.digests, event.digests);
1102    }
1103
1104    #[test]
1105    fn old_event_deserializes_with_defaults() {
1106        // Simulate a v0 event JSON (no schema_version or digests)
1107        let json = r#"{
1108            "event_id": "evt_test",
1109            "ts": "2026-01-01T00:00:00Z",
1110            "type": "note",
1111            "branch": "main",
1112            "parent_hash": null,
1113            "hash": "abc123",
1114            "payload": {"role": "user", "text": "hello", "tags": []}
1115        }"#;
1116        let event: Event = serde_json::from_str(json).unwrap();
1117        assert_eq!(event.schema_version, 0);
1118        assert!(event.digests.is_empty());
1119    }
1120
1121    #[test]
1122    fn digests_not_in_hash_computation() {
1123        let e1 = new_note_event("main", None, "user", "test", &[]).unwrap();
1124
1125        // Remove hash/digests/schema_version and recompute — should match original
1126        let mut val = serde_json::to_value(&e1).unwrap();
1127        let obj = val.as_object_mut().unwrap();
1128        obj.remove("hash");
1129        obj.remove("digests");
1130        obj.remove("schema_version");
1131        let recomputed = compute_event_hash(&val).unwrap();
1132        assert_eq!(recomputed, e1.hash);
1133    }
1134
1135    #[test]
1136    fn schema_version_not_in_hash_computation() {
1137        let e1 = new_note_event("main", None, "user", "test", &[]).unwrap();
1138
1139        // Change schema_version, exclude it, recompute — should still match
1140        let mut e2_val = serde_json::to_value(&e1).unwrap();
1141        let obj = e2_val.as_object_mut().unwrap();
1142        obj.insert("schema_version".to_string(), serde_json::json!(999));
1143        obj.remove("hash");
1144        obj.remove("digests");
1145        obj.remove("schema_version");
1146        let recomputed = compute_event_hash(&e2_val).unwrap();
1147        assert_eq!(recomputed, e1.hash);
1148    }
1149
1150    #[test]
1151    fn old_event_without_provenance_deserializes() {
1152        let json = r#"{
1153            "event_id": "evt_test",
1154            "ts": "2026-01-01T00:00:00Z",
1155            "type": "note",
1156            "branch": "main",
1157            "parent_hash": null,
1158            "hash": "abc123",
1159            "payload": {"role": "user", "text": "hello", "tags": []},
1160            "refs": {"blobs": [], "events": ["evt_other"]}
1161        }"#;
1162        let event: Event = serde_json::from_str(json).unwrap();
1163        assert!(event.refs.provenance.is_empty());
1164        assert_eq!(event.refs.events, vec!["evt_other"]);
1165    }
1166
1167    #[test]
1168    fn provenance_included_in_hash() {
1169        use crate::types::{rel, Provenance};
1170
1171        let e1 = new_note_event("main", None, "user", "test", &[]).unwrap();
1172        let mut e2 = new_note_event("main", None, "user", "test", &[]).unwrap();
1173        // Force same event_id and ts so we can compare hashes
1174        e2.event_id = e1.event_id.clone();
1175        e2.ts = e1.ts.clone();
1176        finalize_event(&mut e2).unwrap();
1177        assert_eq!(e1.hash, e2.hash);
1178
1179        // Add provenance and re-finalize
1180        e2.refs.provenance.push(Provenance {
1181            target: "evt_other".to_string(),
1182            rel: rel::BASED_ON.to_string(),
1183            note: None,
1184        });
1185        finalize_event(&mut e2).unwrap();
1186        assert_ne!(e1.hash, e2.hash);
1187    }
1188
1189    #[test]
1190    fn provenance_round_trip_serialize() {
1191        use crate::types::{rel, Provenance};
1192
1193        let mut event = new_note_event("main", None, "user", "test", &[]).unwrap();
1194        event.refs.provenance.push(Provenance {
1195            target: "evt_abc".to_string(),
1196            rel: rel::REVIEWS.to_string(),
1197            note: Some("review note".to_string()),
1198        });
1199        finalize_event(&mut event).unwrap();
1200
1201        let json = serde_json::to_string(&event).unwrap();
1202        let deserialized: Event = serde_json::from_str(&json).unwrap();
1203        assert_eq!(deserialized.refs.provenance.len(), 1);
1204        assert_eq!(deserialized.refs.provenance[0].target, "evt_abc");
1205        assert_eq!(deserialized.refs.provenance[0].rel, rel::REVIEWS);
1206        assert_eq!(
1207            deserialized.refs.provenance[0].note.as_deref(),
1208            Some("review note")
1209        );
1210        assert_eq!(deserialized.hash, event.hash);
1211    }
1212
1213    #[test]
1214    fn empty_provenance_not_serialized() {
1215        let event = new_note_event("main", None, "user", "test", &[]).unwrap();
1216        let json = serde_json::to_string(&event).unwrap();
1217        assert!(!json.contains("provenance"));
1218    }
1219
1220    #[test]
1221    fn taxonomy_note_is_signal_info() {
1222        let event = new_note_event("main", None, "user", "test", &[]).unwrap();
1223        assert_eq!(event.event_family.as_deref(), Some("signal"));
1224        assert_eq!(event.event_level.as_deref(), Some("info"));
1225    }
1226
1227    #[test]
1228    fn taxonomy_cmd_is_signal_trace() {
1229        let argv = vec!["echo".to_string()];
1230        let event = new_cmd_event(&CmdEventParams {
1231            branch: "main",
1232            parent_hash: None,
1233            argv: &argv,
1234            cwd: ".",
1235            exit_code: 0,
1236            duration_ms: 10,
1237            stdout_blob: "",
1238            stderr_blob: "",
1239        })
1240        .unwrap();
1241        assert_eq!(event.event_family.as_deref(), Some("signal"));
1242        assert_eq!(event.event_level.as_deref(), Some("trace"));
1243    }
1244
1245    #[test]
1246    fn taxonomy_commit_is_milestone() {
1247        let event = new_commit_event(&mut CommitEventParams {
1248            branch: "main",
1249            parent_hash: None,
1250            title: "test",
1251            purpose: None,
1252            prev_summary: "",
1253            contribution: "x",
1254            evidence: vec![],
1255            labels: vec![],
1256        })
1257        .unwrap();
1258        assert_eq!(event.event_family.as_deref(), Some("milestone"));
1259        assert_eq!(event.event_level.as_deref(), Some("milestone"));
1260    }
1261
1262    #[test]
1263    fn taxonomy_rebuild_is_admin_trace() {
1264        let event = new_rebuild_event("main", None, "all", None, "rebuild").unwrap();
1265        assert_eq!(event.event_family.as_deref(), Some("admin"));
1266        assert_eq!(event.event_level.as_deref(), Some("trace"));
1267    }
1268
1269    #[test]
1270    fn taxonomy_approval_is_governance() {
1271        let event = new_approval_event(&ApprovalEventParams {
1272            branch: "main",
1273            parent_hash: None,
1274            draft_id: "drf_test",
1275            draft_sha256: "abc",
1276            decision: "approve",
1277            actor: "alice",
1278            note: "",
1279            stage_id: "lead",
1280            role: "lead",
1281            device_id: None,
1282        })
1283        .unwrap();
1284        assert_eq!(event.event_family.as_deref(), Some("governance"));
1285        assert_eq!(event.event_level.as_deref(), Some("governance"));
1286    }
1287
1288    #[test]
1289    fn taxonomy_backward_compat_old_event_no_family() {
1290        let json = r#"{
1291            "event_id": "evt_old",
1292            "ts": "2026-01-01T00:00:00Z",
1293            "type": "note",
1294            "branch": "main",
1295            "parent_hash": null,
1296            "hash": "abc123",
1297            "payload": {"role": "user", "text": "hello", "tags": []}
1298        }"#;
1299        let event: Event = serde_json::from_str(json).unwrap();
1300        assert!(event.event_family.is_none());
1301        assert!(event.event_level.is_none());
1302    }
1303
1304    #[test]
1305    fn taxonomy_round_trip_preserves_fields() {
1306        let event = new_note_event("main", None, "user", "test", &[]).unwrap();
1307        let json = serde_json::to_string(&event).unwrap();
1308        let deserialized: Event = serde_json::from_str(&json).unwrap();
1309        assert_eq!(deserialized.event_family, event.event_family);
1310        assert_eq!(deserialized.event_level, event.event_level);
1311    }
1312
1313    #[test]
1314    fn classify_unknown_type_returns_none() {
1315        use crate::types::classify_event_type;
1316        let (f, l) = classify_event_type("unknown_custom_type");
1317        assert!(f.is_none());
1318        assert!(l.is_none());
1319    }
1320
1321    #[test]
1322    fn rel_constants_are_correct() {
1323        use crate::types::rel;
1324        assert_eq!(rel::BASED_ON, "based_on");
1325        assert_eq!(rel::SUPERSEDES, "supersedes");
1326        assert_eq!(rel::CONTINUES, "continues");
1327        assert_eq!(rel::REVIEWS, "reviews");
1328    }
1329
1330    #[test]
1331    fn task_intake_event_has_correct_structure() {
1332        let params = TaskIntakeParams {
1333            branch: "main".to_string(),
1334            parent_hash: None,
1335            source: "github_issue".to_string(),
1336            source_id: "45".to_string(),
1337            source_url: "https://github.com/owner/repo/issues/45".to_string(),
1338            title: "Add user authentication".to_string(),
1339            intent: "implement".to_string(),
1340            labels: vec!["enhancement".to_string()],
1341            priority: "normal".to_string(),
1342            constraints: vec![],
1343        };
1344        let event = new_task_intake_event(&params).unwrap();
1345        assert_eq!(event.event_type, "task_intake");
1346        assert!(event.event_id.starts_with("evt_"));
1347        assert_eq!(event.hash.len(), 64);
1348        assert_eq!(event.branch, "main");
1349        assert_eq!(event.payload["source"], "github_issue");
1350        assert_eq!(event.payload["source_id"], "45");
1351        assert_eq!(event.payload["title"], "Add user authentication");
1352        assert_eq!(event.payload["intent"], "implement");
1353        assert_eq!(event.payload["labels"][0], "enhancement");
1354        assert_eq!(event.payload["priority"], "normal");
1355    }
1356
1357    #[test]
1358    fn task_intake_classification() {
1359        use crate::types::{classify_event_type, event_family, event_level};
1360        let (f, l) = classify_event_type("task_intake");
1361        assert_eq!(f, Some(event_family::SIGNAL));
1362        assert_eq!(l, Some(event_level::INFO));
1363    }
1364
1365    #[test]
1366    fn task_intake_taxonomy_auto_set() {
1367        let params = TaskIntakeParams {
1368            branch: "main".to_string(),
1369            parent_hash: Some("abc123".to_string()),
1370            source: "github_issue".to_string(),
1371            source_id: "112".to_string(),
1372            source_url: "https://github.com/owner/repo/issues/112".to_string(),
1373            title: "feat: task intake".to_string(),
1374            intent: "implement".to_string(),
1375            labels: vec![],
1376            priority: "normal".to_string(),
1377            constraints: vec![],
1378        };
1379        let event = new_task_intake_event(&params).unwrap();
1380        assert_eq!(event.event_family.as_deref(), Some("signal"));
1381        assert_eq!(event.event_level.as_deref(), Some("info"));
1382        assert_eq!(event.parent_hash.as_deref(), Some("abc123"));
1383    }
1384
1385    #[test]
1386    fn review_bundle_event_fields() {
1387        use crate::bundle::*;
1388
1389        let bundle = ReviewBundle {
1390            bundle_id: "bun_test".into(),
1391            change_summary: ChangeSummary {
1392                files: vec![FileChange {
1393                    path: "src/main.rs".into(),
1394                    added: 10,
1395                    deleted: 3,
1396                }],
1397                total_added: 10,
1398                total_deleted: 3,
1399                diff_ref: "HEAD~1".into(),
1400            },
1401            test_results: TestResults {
1402                passed: 50,
1403                failed: 0,
1404                ignored: 2,
1405                total: 52,
1406                failures: vec![],
1407                command: "cargo test".into(),
1408            },
1409            risk_assessment: RiskAssessment {
1410                level: RiskLevel::Low,
1411                factors: vec![],
1412            },
1413            suggested_action: SuggestedAction::Approve,
1414            suggested_reason: "All tests pass".into(),
1415        };
1416
1417        let params = ReviewBundleParams {
1418            branch: "main".into(),
1419            parent_hash: None,
1420            bundle,
1421        };
1422        let event = new_review_bundle_event(&params).unwrap();
1423        assert_eq!(event.event_type, "review_bundle");
1424        assert!(event.event_id.starts_with("evt_"));
1425        assert_eq!(event.hash.len(), 64);
1426        assert_eq!(event.payload["bundle_id"], "bun_test");
1427        assert_eq!(event.payload["change_summary"]["total_added"], 10);
1428        assert_eq!(event.payload["test_results"]["passed"], 50);
1429        assert_eq!(event.payload["risk_assessment"]["level"], "low");
1430        assert_eq!(event.payload["suggested_action"], "approve");
1431    }
1432
1433    #[test]
1434    fn review_bundle_taxonomy() {
1435        use crate::types::{classify_event_type, event_family, event_level};
1436        let (f, l) = classify_event_type("review_bundle");
1437        assert_eq!(f, Some(event_family::GOVERNANCE));
1438        assert_eq!(l, Some(event_level::MILESTONE));
1439    }
1440
1441    #[test]
1442    fn agent_phase_change_event_fields() {
1443        let signals = vec!["branch feat/auth-45 exists".to_string()];
1444        let event = new_agent_phase_change_event(&AgentPhaseChangeParams {
1445            branch: "main",
1446            parent_hash: None,
1447            session_id: "sess-abc",
1448            label: Some("feature-worker"),
1449            from: "research",
1450            to: "implement",
1451            issue: Some(45),
1452            confidence: 0.85,
1453            signals: &signals,
1454        })
1455        .unwrap();
1456        assert_eq!(event.event_type, "agent_phase_change");
1457        assert_eq!(event.payload["session_id"], "sess-abc");
1458        assert_eq!(event.payload["label"], "feature-worker");
1459        assert_eq!(event.payload["from"], "research");
1460        assert_eq!(event.payload["to"], "implement");
1461        assert_eq!(event.payload["issue"], 45);
1462        let conf = event.payload["confidence"].as_f64().unwrap();
1463        assert!((conf - 0.85).abs() < 0.001);
1464        assert_eq!(event.schema_version, SCHEMA_VERSION);
1465        assert_eq!(event.digests[0].value, event.hash);
1466    }
1467
1468    #[test]
1469    fn agent_phase_change_event_minimal() {
1470        let event = new_agent_phase_change_event(&AgentPhaseChangeParams {
1471            branch: "main",
1472            parent_hash: None,
1473            session_id: "sess-xyz",
1474            label: None,
1475            from: "triage",
1476            to: "research",
1477            issue: None,
1478            confidence: 0.6,
1479            signals: &[],
1480        })
1481        .unwrap();
1482        assert_eq!(event.event_type, "agent_phase_change");
1483        assert!(event.payload.get("label").is_none());
1484        assert!(event.payload.get("issue").is_none());
1485    }
1486
1487    #[test]
1488    fn taxonomy_agent_phase_change_is_signal_info() {
1489        let event = new_agent_phase_change_event(&AgentPhaseChangeParams {
1490            branch: "main",
1491            parent_hash: None,
1492            session_id: "sess-test",
1493            label: None,
1494            from: "triage",
1495            to: "research",
1496            issue: None,
1497            confidence: 0.7,
1498            signals: &[],
1499        })
1500        .unwrap();
1501        assert_eq!(event.event_family.as_deref(), Some("signal"));
1502        assert_eq!(event.event_level.as_deref(), Some("info"));
1503    }
1504
1505    #[test]
1506    fn finalize_coerces_nan_to_null() {
1507        // serde_json::json! converts f64::NAN to Value::Null, so finalize
1508        // succeeds — NaN never reaches serialization.
1509        let mut event = Event {
1510            event_id: "test".into(),
1511            ts: "2026-01-01T00:00:00Z".into(),
1512            event_type: "note".into(),
1513            branch: "main".into(),
1514            parent_hash: None,
1515            hash: String::new(),
1516            payload: serde_json::json!({"value": f64::NAN}),
1517            refs: Refs::default(),
1518            schema_version: 1,
1519            digests: Vec::new(),
1520            event_family: None,
1521            event_level: None,
1522        };
1523        assert!(
1524            event.payload["value"].is_null(),
1525            "NAN should be coerced to null by json! macro"
1526        );
1527        assert!(finalize_event(&mut event).is_ok());
1528    }
1529}