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
7pub 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
24fn 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 set_taxonomy(event);
34
35 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
53pub fn finalize_event(event: &mut Event) -> anyhow::Result<()> {
56 finalize(event)
57}
58
59pub 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
92pub 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
110pub 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
122pub 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
163pub 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
175pub fn new_commit_event(params: &mut CommitEventParams<'_>) -> anyhow::Result<Event> {
178 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 let mut event_refs = Vec::new();
194 for item in ¶ms.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
222pub 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
255pub 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
290pub 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
321pub 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
356pub 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 pub device_id: Option<&'a str>,
369}
370
371pub 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
405pub 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
418pub 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
449pub 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
463pub 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
495pub 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
508pub 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
543pub struct ReviewBundleParams {
545 pub branch: String,
546 pub parent_hash: Option<String>,
547 pub bundle: crate::bundle::ReviewBundle,
548}
549
550pub fn new_review_bundle_event(params: &ReviewBundleParams) -> anyhow::Result<Event> {
552 let payload = serde_json::to_value(¶ms.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#[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
589pub 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
622pub 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
670pub 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
702pub 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#[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
747pub 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) = ¶ms.matched_rule {
759 payload["matched_rule"] = serde_json::json!(rule);
760 }
761 if let Some(risk) = ¶ms.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 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 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 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 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 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 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 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(¶ms).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(¶ms).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(¶ms).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 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}