use crate::canon::canonical_json_bytes;
use crate::hash::sha256_hex;
use crate::types::{
classify_event_type, DecisionPayload, Digest, Event, Refs, CANON_EDDA_V1, SCHEMA_VERSION,
};
pub fn compute_event_hash(event_without_hash: &serde_json::Value) -> anyhow::Result<String> {
let bytes = canonical_json_bytes(event_without_hash)?;
Ok(sha256_hex(&bytes))
}
fn new_event_id() -> String {
format!("evt_{}", ulid::Ulid::new().to_string().to_lowercase())
}
fn now_rfc3339() -> String {
let now = time::OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.expect("RFC3339 formatting should not fail")
}
fn set_taxonomy(event: &mut Event) {
let (family, level) = classify_event_type(&event.event_type);
event.event_family = family.map(|s| s.to_string());
event.event_level = level.map(|s| s.to_string());
}
fn finalize(event: &mut Event) -> anyhow::Result<()> {
set_taxonomy(event);
let mut val = serde_json::to_value(&*event)?;
if let Some(obj) = val.as_object_mut() {
obj.remove("hash");
obj.remove("digests");
obj.remove("schema_version");
}
let hash_value = compute_event_hash(&val)?;
event.hash = hash_value.clone();
event.digests = vec![Digest {
alg: "sha256".to_string(),
canon: CANON_EDDA_V1.to_string(),
value: hash_value,
}];
Ok(())
}
pub fn finalize_event(event: &mut Event) -> anyhow::Result<()> {
finalize(event)
}
pub fn new_note_event(
branch: &str,
parent_hash: Option<&str>,
role: &str,
text: &str,
tags: &[String],
) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"role": role,
"text": text,
"tags": tags,
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "note".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_decision_event(
branch: &str,
parent_hash: Option<&str>,
role: &str,
decision: &DecisionPayload,
) -> anyhow::Result<Event> {
let text = match &decision.reason {
Some(r) => format!("{}: {} \u{2014} {}", decision.key, decision.value, r),
None => format!("{}: {}", decision.key, decision.value),
};
let tags = vec!["decision".to_string()];
let mut event = new_note_event(branch, parent_hash, role, &text, &tags)?;
event.payload["decision"] = serde_json::to_value(decision)?;
finalize(&mut event)?;
Ok(event)
}
pub struct CmdEventParams<'a> {
pub branch: &'a str,
pub parent_hash: Option<&'a str>,
pub argv: &'a [String],
pub cwd: &'a str,
pub exit_code: i32,
pub duration_ms: u64,
pub stdout_blob: &'a str,
pub stderr_blob: &'a str,
}
pub fn new_cmd_event(params: &CmdEventParams<'_>) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"argv": params.argv,
"cwd": params.cwd,
"exit_code": params.exit_code,
"duration_ms": params.duration_ms,
"stdout_blob": params.stdout_blob,
"stderr_blob": params.stderr_blob,
});
let mut blob_refs = Vec::new();
if !params.stdout_blob.is_empty() {
blob_refs.push(params.stdout_blob.to_string());
}
if !params.stderr_blob.is_empty() {
blob_refs.push(params.stderr_blob.to_string());
}
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "cmd".to_string(),
branch: params.branch.to_string(),
parent_hash: params.parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs {
blobs: blob_refs,
..Default::default()
},
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub struct CommitEventParams<'a> {
pub branch: &'a str,
pub parent_hash: Option<&'a str>,
pub title: &'a str,
pub purpose: Option<&'a str>,
pub prev_summary: &'a str,
pub contribution: &'a str,
pub evidence: Vec<serde_json::Value>,
pub labels: Vec<String>,
}
pub fn new_commit_event(params: &mut CommitEventParams<'_>) -> anyhow::Result<Event> {
if params.evidence.is_empty() && !params.labels.iter().any(|l| l == "claim") {
params.labels.push("claim".to_string());
}
let payload = serde_json::json!({
"title": params.title,
"purpose": params.purpose.unwrap_or(""),
"prev_summary": params.prev_summary,
"contribution": params.contribution,
"evidence": params.evidence,
"labels": params.labels,
});
let mut event_refs = Vec::new();
for item in ¶ms.evidence {
if let Some(eid) = item.get("event_id").and_then(|v| v.as_str()) {
event_refs.push(eid.to_string());
}
}
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "commit".to_string(),
branch: params.branch.to_string(),
parent_hash: params.parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs {
events: event_refs,
..Default::default()
},
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_rebuild_event(
branch: &str,
parent_hash: Option<&str>,
scope: &str,
target_branch: Option<&str>,
reason: &str,
) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"scope": scope,
"branch": target_branch.unwrap_or(""),
"reason": reason,
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "rebuild".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_branch_create_event(
branch: &str,
parent_hash: Option<&str>,
name: &str,
purpose: &str,
from_branch: &str,
from_event_id: Option<&str>,
) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"name": name,
"purpose": purpose,
"from_branch": from_branch,
"from_event_id": from_event_id.unwrap_or(""),
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "branch_create".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_branch_switch_event(
branch: &str,
parent_hash: Option<&str>,
from: &str,
to: &str,
) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"from": from,
"to": to,
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "branch_switch".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_merge_event(
branch: &str,
parent_hash: Option<&str>,
src: &str,
dst: &str,
reason: &str,
adopted_commits: &[String],
) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"src": src,
"dst": dst,
"reason": reason,
"adopted_commits": adopted_commits,
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "merge".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub struct ApprovalEventParams<'a> {
pub branch: &'a str,
pub parent_hash: Option<&'a str>,
pub draft_id: &'a str,
pub draft_sha256: &'a str,
pub decision: &'a str,
pub actor: &'a str,
pub note: &'a str,
pub stage_id: &'a str,
pub role: &'a str,
pub device_id: Option<&'a str>,
}
pub fn new_approval_event(p: &ApprovalEventParams<'_>) -> anyhow::Result<Event> {
let mut payload = serde_json::json!({
"draft_id": p.draft_id,
"draft_sha256": p.draft_sha256,
"decision": p.decision,
"actor": p.actor,
"note": p.note,
"stage_id": p.stage_id,
"role": p.role,
});
if let Some(device_id) = p.device_id {
payload["device_id"] = serde_json::Value::String(device_id.to_string());
}
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "approval".to_string(),
branch: p.branch.to_string(),
parent_hash: p.parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub struct ApprovalRequestParams<'a> {
pub branch: &'a str,
pub parent_hash: Option<&'a str>,
pub draft_id: &'a str,
pub draft_sha256: &'a str,
pub route_rule_id: &'a str,
pub stage_id: &'a str,
pub role: &'a str,
pub assignees: &'a [String],
pub reason: &'a str,
}
pub fn new_approval_request_event(p: &ApprovalRequestParams<'_>) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"draft_id": p.draft_id,
"draft_sha256": p.draft_sha256,
"route_rule_id": p.route_rule_id,
"stage_id": p.stage_id,
"role": p.role,
"assignees": p.assignees,
"reason": p.reason,
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "approval_request".to_string(),
branch: p.branch.to_string(),
parent_hash: p.parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub struct TaskIntakeParams {
pub branch: String,
pub parent_hash: Option<String>,
pub source: String,
pub source_id: String,
pub source_url: String,
pub title: String,
pub intent: String,
pub labels: Vec<String>,
pub priority: String,
pub constraints: Vec<String>,
}
pub fn new_task_intake_event(params: &TaskIntakeParams) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"source": params.source,
"source_id": params.source_id,
"source_url": params.source_url,
"title": params.title,
"intent": params.intent,
"labels": params.labels,
"priority": params.priority,
"constraints": params.constraints,
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "task_intake".to_string(),
branch: params.branch.clone(),
parent_hash: params.parent_hash.clone(),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub struct AgentPhaseChangeParams<'a> {
pub branch: &'a str,
pub parent_hash: Option<&'a str>,
pub session_id: &'a str,
pub label: Option<&'a str>,
pub from: &'a str,
pub to: &'a str,
pub issue: Option<u64>,
pub confidence: f32,
pub signals: &'a [String],
}
pub fn new_agent_phase_change_event(p: &AgentPhaseChangeParams<'_>) -> anyhow::Result<Event> {
let mut payload = serde_json::json!({
"session_id": p.session_id,
"from": p.from,
"to": p.to,
"confidence": p.confidence,
"signals": p.signals,
});
if let Some(label) = p.label {
payload["label"] = serde_json::json!(label);
}
if let Some(issue) = p.issue {
payload["issue"] = serde_json::json!(issue);
}
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "agent_phase_change".to_string(),
branch: p.branch.to_string(),
parent_hash: p.parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub struct ReviewBundleParams {
pub branch: String,
pub parent_hash: Option<String>,
pub bundle: crate::bundle::ReviewBundle,
}
pub fn new_review_bundle_event(params: &ReviewBundleParams) -> anyhow::Result<Event> {
let payload = serde_json::to_value(¶ms.bundle)?;
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "review_bundle".to_string(),
branch: params.branch.clone(),
parent_hash: params.parent_hash.clone(),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
#[derive(Debug, Clone)]
pub struct PrEventParams {
pub branch: String,
pub parent_hash: Option<String>,
pub pr_number: u64,
pub pr_status: String,
pub review_result: Option<String>,
pub blocker_count: u32,
pub time_to_merge_hours: Option<f64>,
pub created_at: String,
pub merged_at: Option<String>,
pub author: String,
pub title: String,
}
pub fn new_pr_event(params: &PrEventParams) -> anyhow::Result<Event> {
let payload = serde_json::json!({
"pr_number": params.pr_number,
"pr_status": params.pr_status,
"review_result": params.review_result,
"blocker_count": params.blocker_count,
"time_to_merge_hours": params.time_to_merge_hours,
"created_at": params.created_at,
"merged_at": params.merged_at,
"author": params.author,
"title": params.title,
});
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "pr".to_string(),
branch: params.branch.clone(),
parent_hash: params.parent_hash.clone(),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_execution_event(
branch: &str,
parent_hash: Option<&str>,
event_id_override: &str,
occurred_at: &str,
payload: serde_json::Value,
decision_ref: Option<&str>,
) -> anyhow::Result<Event> {
use crate::types::{rel, Provenance};
let refs = if let Some(dec_ref) = decision_ref {
Refs {
provenance: vec![Provenance {
target: dec_ref.to_string(),
rel: rel::BASED_ON.to_string(),
note: Some("karvi decision_ref".to_string()),
}],
..Default::default()
}
} else {
Refs::default()
};
let mut event = Event {
event_id: event_id_override.to_string(),
ts: occurred_at.to_string(),
event_type: "execution_event".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs,
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_snapshot_event(
branch: &str,
parent_hash: Option<&str>,
payload: serde_json::Value,
blob_refs: Vec<String>,
) -> anyhow::Result<Event> {
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "decide_snapshot".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs {
blobs: blob_refs,
..Default::default()
},
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
pub fn new_telemetry_event(
branch: &str,
parent_hash: Option<&str>,
cycle_id: &str,
started_at: &str,
payload: serde_json::Value,
) -> anyhow::Result<Event> {
let mut event = Event {
event_id: cycle_id.to_string(),
ts: started_at.to_string(),
event_type: "cycle_telemetry".to_string(),
branch: branch.to_string(),
parent_hash: parent_hash.map(|s| s.to_string()),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
#[derive(Debug, Clone)]
pub struct ApprovalPolicyMatchParams {
pub branch: String,
pub parent_hash: Option<String>,
pub task_id: String,
pub step: String,
pub matched_rule: Option<String>,
pub action: String,
pub reason: String,
pub risk_level: Option<String>,
pub files_changed: Option<u32>,
}
pub fn new_approval_policy_match_event(
params: &ApprovalPolicyMatchParams,
) -> anyhow::Result<Event> {
let mut payload = serde_json::json!({
"task_id": params.task_id,
"step": params.step,
"action": params.action,
"reason": params.reason,
});
if let Some(rule) = ¶ms.matched_rule {
payload["matched_rule"] = serde_json::json!(rule);
}
if let Some(risk) = ¶ms.risk_level {
payload["risk_level"] = serde_json::json!(risk);
}
if let Some(fc) = params.files_changed {
payload["files_changed"] = serde_json::json!(fc);
}
let mut event = Event {
event_id: new_event_id(),
ts: now_rfc3339(),
event_type: "approval_policy_match".to_string(),
branch: params.branch.clone(),
parent_hash: params.parent_hash.clone(),
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize(&mut event)?;
Ok(event)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_event_has_valid_id_and_hash() {
let event = new_note_event("main", None, "user", "hello", &[]).unwrap();
assert!(event.event_id.starts_with("evt_"));
assert_eq!(event.hash.len(), 64);
assert!(event.hash.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(event.event_type, "note");
assert!(event.parent_hash.is_none());
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests.len(), 1);
assert_eq!(event.digests[0].alg, "sha256");
assert_eq!(event.digests[0].canon, CANON_EDDA_V1);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn decision_event_has_structured_payload() {
let dp = DecisionPayload {
key: "db.engine".to_string(),
value: "sqlite".to_string(),
reason: Some("embedded, zero-config".to_string()),
scope: None,
authority: None,
affected_paths: None,
tags: None,
review_after: None,
reversibility: None,
village_id: None,
};
let event = new_decision_event("main", None, "system", &dp).unwrap();
assert_eq!(event.event_type, "note");
assert_eq!(event.payload["decision"]["key"], "db.engine");
assert_eq!(event.payload["decision"]["value"], "sqlite");
assert_eq!(event.payload["decision"]["reason"], "embedded, zero-config");
assert_eq!(
event.payload["text"],
"db.engine: sqlite \u{2014} embedded, zero-config"
);
let tags = event.payload["tags"].as_array().unwrap();
assert!(tags.iter().any(|t| t.as_str() == Some("decision")));
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn decision_event_without_reason() {
let dp = DecisionPayload {
key: "auth.method".to_string(),
value: "JWT".to_string(),
reason: None,
scope: None,
authority: None,
affected_paths: None,
tags: None,
review_after: None,
reversibility: None,
village_id: None,
};
let event = new_decision_event("main", None, "system", &dp).unwrap();
assert_eq!(event.payload["decision"]["key"], "auth.method");
assert_eq!(event.payload["decision"]["value"], "JWT");
assert!(event.payload["decision"].get("reason").is_none());
assert_eq!(event.payload["text"], "auth.method: JWT");
}
#[test]
fn decision_event_extractable() {
let dp = DecisionPayload {
key: "api.style".to_string(),
value: "REST".to_string(),
reason: Some("compatibility".to_string()),
scope: None,
authority: None,
affected_paths: None,
tags: None,
review_after: None,
reversibility: None,
village_id: None,
};
let event = new_decision_event("main", None, "system", &dp).unwrap();
let extracted = crate::decision::extract_decision(&event.payload).unwrap();
assert_eq!(extracted, dp);
}
#[test]
fn note_event_parent_hash_propagates() {
let e1 = new_note_event("main", None, "user", "first", &[]).unwrap();
let e2 = new_note_event("main", Some(&e1.hash), "user", "second", &[]).unwrap();
assert_eq!(e2.parent_hash.as_deref(), Some(e1.hash.as_str()));
}
#[test]
fn cmd_event_has_blob_refs() {
let argv = vec!["echo".to_string(), "hi".to_string()];
let event = new_cmd_event(&CmdEventParams {
branch: "main",
parent_hash: None,
argv: &argv,
cwd: ".",
exit_code: 0,
duration_ms: 100,
stdout_blob: "blob:sha256:aaa",
stderr_blob: "blob:sha256:bbb",
})
.unwrap();
assert_eq!(event.event_type, "cmd");
assert_eq!(event.refs.blobs.len(), 2);
assert_eq!(event.refs.blobs[0], "blob:sha256:aaa");
assert!(event.refs.provenance.is_empty());
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests.len(), 1);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn hash_is_deterministic_for_same_content() {
let event = new_note_event("main", None, "user", "test", &[]).unwrap();
let mut val = serde_json::to_value(&event).unwrap();
let obj = val.as_object_mut().unwrap();
obj.remove("hash");
obj.remove("digests");
obj.remove("schema_version");
let recomputed = compute_event_hash(&val).unwrap();
assert_eq!(recomputed, event.hash);
}
#[test]
fn commit_event_auto_claim_when_no_evidence() {
let event = new_commit_event(&mut CommitEventParams {
branch: "main",
parent_hash: None,
title: "test commit",
purpose: None,
prev_summary: "",
contribution: "did something",
evidence: vec![],
labels: vec![],
})
.unwrap();
assert_eq!(event.event_type, "commit");
let labels = event.payload["labels"].as_array().unwrap();
assert!(labels.iter().any(|l| l.as_str() == Some("claim")));
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn commit_event_no_auto_claim_with_evidence() {
let evidence = vec![serde_json::json!({"event_id": "evt_test", "why": "passed"})];
let event = new_commit_event(&mut CommitEventParams {
branch: "main",
parent_hash: None,
title: "verified commit",
purpose: Some("testing"),
prev_summary: "prev",
contribution: "this",
evidence,
labels: vec!["safe".to_string()],
})
.unwrap();
let labels = event.payload["labels"].as_array().unwrap();
assert!(!labels.iter().any(|l| l.as_str() == Some("claim")));
assert!(labels.iter().any(|l| l.as_str() == Some("safe")));
assert_eq!(event.refs.events, vec!["evt_test"]);
assert!(event.refs.provenance.is_empty());
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn rebuild_event_fields() {
let event = new_rebuild_event("main", None, "all", None, "rebuild views").unwrap();
assert_eq!(event.event_type, "rebuild");
assert_eq!(event.payload["scope"], "all");
assert_eq!(event.payload["reason"], "rebuild views");
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn branch_create_event_fields() {
let event = new_branch_create_event(
"main",
None,
"feat/x",
"try alternative",
"main",
Some("evt_test"),
)
.unwrap();
assert_eq!(event.event_type, "branch_create");
assert_eq!(event.payload["name"], "feat/x");
assert_eq!(event.payload["purpose"], "try alternative");
assert_eq!(event.payload["from_branch"], "main");
assert_eq!(event.payload["from_event_id"], "evt_test");
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn branch_switch_event_fields() {
let event = new_branch_switch_event("feat/x", None, "main", "feat/x").unwrap();
assert_eq!(event.event_type, "branch_switch");
assert_eq!(event.payload["from"], "main");
assert_eq!(event.payload["to"], "feat/x");
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn merge_event_fields() {
let adopted = vec!["evt_a".to_string(), "evt_b".to_string()];
let event =
new_merge_event("main", None, "feat/x", "main", "accept feature", &adopted).unwrap();
assert_eq!(event.event_type, "merge");
assert_eq!(event.payload["src"], "feat/x");
assert_eq!(event.payload["dst"], "main");
assert_eq!(event.payload["reason"], "accept feature");
let ac = event.payload["adopted_commits"].as_array().unwrap();
assert_eq!(ac.len(), 2);
assert_eq!(ac[0], "evt_a");
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn approval_event_fields() {
let event = new_approval_event(&ApprovalEventParams {
branch: "main",
parent_hash: None,
draft_id: "drf_test123",
draft_sha256: "sha256abc",
decision: "approve",
actor: "alice",
note: "LGTM",
stage_id: "lead",
role: "lead",
device_id: None,
})
.unwrap();
assert_eq!(event.event_type, "approval");
assert_eq!(event.payload["draft_id"], "drf_test123");
assert_eq!(event.payload["decision"], "approve");
assert_eq!(event.payload["actor"], "alice");
assert_eq!(event.payload["stage_id"], "lead");
assert_eq!(event.payload["role"], "lead");
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
assert!(event.payload.get("device_id").is_none());
}
#[test]
fn approval_event_with_device_id() {
let event = new_approval_event(&ApprovalEventParams {
branch: "main",
parent_hash: None,
draft_id: "drf_dev",
draft_sha256: "abc123",
decision: "approve",
actor: "alice",
note: "approved from phone",
stage_id: "lead",
role: "lead",
device_id: Some("iphone-14-abc"),
})
.unwrap();
assert_eq!(event.payload["device_id"], "iphone-14-abc");
assert_eq!(event.payload["actor"], "alice");
}
#[test]
fn approval_request_event_fields() {
let assignees = vec!["alice".to_string(), "bob".to_string()];
let event = new_approval_request_event(&ApprovalRequestParams {
branch: "main",
parent_hash: None,
draft_id: "drf_test456",
draft_sha256: "sha256def",
route_rule_id: "risky",
stage_id: "lead",
role: "lead",
assignees: &assignees,
reason: "matched rule risky",
})
.unwrap();
assert_eq!(event.event_type, "approval_request");
assert_eq!(event.payload["draft_id"], "drf_test456");
assert_eq!(event.payload["route_rule_id"], "risky");
assert_eq!(event.payload["stage_id"], "lead");
assert_eq!(event.payload["role"], "lead");
let a = event.payload["assignees"].as_array().unwrap();
assert_eq!(a.len(), 2);
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn event_round_trip_serialize() {
let event = new_note_event("main", None, "user", "test", &["todo".to_string()]).unwrap();
let json = serde_json::to_string(&event).unwrap();
let deserialized: Event = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.event_id, event.event_id);
assert_eq!(deserialized.hash, event.hash);
assert_eq!(deserialized.event_type, event.event_type);
assert_eq!(deserialized.branch, event.branch);
assert_eq!(deserialized.parent_hash, event.parent_hash);
assert_eq!(deserialized.schema_version, event.schema_version);
assert_eq!(deserialized.digests, event.digests);
}
#[test]
fn old_event_deserializes_with_defaults() {
let json = r#"{
"event_id": "evt_test",
"ts": "2026-01-01T00:00:00Z",
"type": "note",
"branch": "main",
"parent_hash": null,
"hash": "abc123",
"payload": {"role": "user", "text": "hello", "tags": []}
}"#;
let event: Event = serde_json::from_str(json).unwrap();
assert_eq!(event.schema_version, 0);
assert!(event.digests.is_empty());
}
#[test]
fn digests_not_in_hash_computation() {
let e1 = new_note_event("main", None, "user", "test", &[]).unwrap();
let mut val = serde_json::to_value(&e1).unwrap();
let obj = val.as_object_mut().unwrap();
obj.remove("hash");
obj.remove("digests");
obj.remove("schema_version");
let recomputed = compute_event_hash(&val).unwrap();
assert_eq!(recomputed, e1.hash);
}
#[test]
fn schema_version_not_in_hash_computation() {
let e1 = new_note_event("main", None, "user", "test", &[]).unwrap();
let mut e2_val = serde_json::to_value(&e1).unwrap();
let obj = e2_val.as_object_mut().unwrap();
obj.insert("schema_version".to_string(), serde_json::json!(999));
obj.remove("hash");
obj.remove("digests");
obj.remove("schema_version");
let recomputed = compute_event_hash(&e2_val).unwrap();
assert_eq!(recomputed, e1.hash);
}
#[test]
fn old_event_without_provenance_deserializes() {
let json = r#"{
"event_id": "evt_test",
"ts": "2026-01-01T00:00:00Z",
"type": "note",
"branch": "main",
"parent_hash": null,
"hash": "abc123",
"payload": {"role": "user", "text": "hello", "tags": []},
"refs": {"blobs": [], "events": ["evt_other"]}
}"#;
let event: Event = serde_json::from_str(json).unwrap();
assert!(event.refs.provenance.is_empty());
assert_eq!(event.refs.events, vec!["evt_other"]);
}
#[test]
fn provenance_included_in_hash() {
use crate::types::{rel, Provenance};
let e1 = new_note_event("main", None, "user", "test", &[]).unwrap();
let mut e2 = new_note_event("main", None, "user", "test", &[]).unwrap();
e2.event_id = e1.event_id.clone();
e2.ts = e1.ts.clone();
finalize_event(&mut e2).unwrap();
assert_eq!(e1.hash, e2.hash);
e2.refs.provenance.push(Provenance {
target: "evt_other".to_string(),
rel: rel::BASED_ON.to_string(),
note: None,
});
finalize_event(&mut e2).unwrap();
assert_ne!(e1.hash, e2.hash);
}
#[test]
fn provenance_round_trip_serialize() {
use crate::types::{rel, Provenance};
let mut event = new_note_event("main", None, "user", "test", &[]).unwrap();
event.refs.provenance.push(Provenance {
target: "evt_abc".to_string(),
rel: rel::REVIEWS.to_string(),
note: Some("review note".to_string()),
});
finalize_event(&mut event).unwrap();
let json = serde_json::to_string(&event).unwrap();
let deserialized: Event = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.refs.provenance.len(), 1);
assert_eq!(deserialized.refs.provenance[0].target, "evt_abc");
assert_eq!(deserialized.refs.provenance[0].rel, rel::REVIEWS);
assert_eq!(
deserialized.refs.provenance[0].note.as_deref(),
Some("review note")
);
assert_eq!(deserialized.hash, event.hash);
}
#[test]
fn empty_provenance_not_serialized() {
let event = new_note_event("main", None, "user", "test", &[]).unwrap();
let json = serde_json::to_string(&event).unwrap();
assert!(!json.contains("provenance"));
}
#[test]
fn taxonomy_note_is_signal_info() {
let event = new_note_event("main", None, "user", "test", &[]).unwrap();
assert_eq!(event.event_family.as_deref(), Some("signal"));
assert_eq!(event.event_level.as_deref(), Some("info"));
}
#[test]
fn taxonomy_cmd_is_signal_trace() {
let argv = vec!["echo".to_string()];
let event = new_cmd_event(&CmdEventParams {
branch: "main",
parent_hash: None,
argv: &argv,
cwd: ".",
exit_code: 0,
duration_ms: 10,
stdout_blob: "",
stderr_blob: "",
})
.unwrap();
assert_eq!(event.event_family.as_deref(), Some("signal"));
assert_eq!(event.event_level.as_deref(), Some("trace"));
}
#[test]
fn taxonomy_commit_is_milestone() {
let event = new_commit_event(&mut CommitEventParams {
branch: "main",
parent_hash: None,
title: "test",
purpose: None,
prev_summary: "",
contribution: "x",
evidence: vec![],
labels: vec![],
})
.unwrap();
assert_eq!(event.event_family.as_deref(), Some("milestone"));
assert_eq!(event.event_level.as_deref(), Some("milestone"));
}
#[test]
fn taxonomy_rebuild_is_admin_trace() {
let event = new_rebuild_event("main", None, "all", None, "rebuild").unwrap();
assert_eq!(event.event_family.as_deref(), Some("admin"));
assert_eq!(event.event_level.as_deref(), Some("trace"));
}
#[test]
fn taxonomy_approval_is_governance() {
let event = new_approval_event(&ApprovalEventParams {
branch: "main",
parent_hash: None,
draft_id: "drf_test",
draft_sha256: "abc",
decision: "approve",
actor: "alice",
note: "",
stage_id: "lead",
role: "lead",
device_id: None,
})
.unwrap();
assert_eq!(event.event_family.as_deref(), Some("governance"));
assert_eq!(event.event_level.as_deref(), Some("governance"));
}
#[test]
fn taxonomy_backward_compat_old_event_no_family() {
let json = r#"{
"event_id": "evt_old",
"ts": "2026-01-01T00:00:00Z",
"type": "note",
"branch": "main",
"parent_hash": null,
"hash": "abc123",
"payload": {"role": "user", "text": "hello", "tags": []}
}"#;
let event: Event = serde_json::from_str(json).unwrap();
assert!(event.event_family.is_none());
assert!(event.event_level.is_none());
}
#[test]
fn taxonomy_round_trip_preserves_fields() {
let event = new_note_event("main", None, "user", "test", &[]).unwrap();
let json = serde_json::to_string(&event).unwrap();
let deserialized: Event = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.event_family, event.event_family);
assert_eq!(deserialized.event_level, event.event_level);
}
#[test]
fn classify_unknown_type_returns_none() {
use crate::types::classify_event_type;
let (f, l) = classify_event_type("unknown_custom_type");
assert!(f.is_none());
assert!(l.is_none());
}
#[test]
fn rel_constants_are_correct() {
use crate::types::rel;
assert_eq!(rel::BASED_ON, "based_on");
assert_eq!(rel::SUPERSEDES, "supersedes");
assert_eq!(rel::CONTINUES, "continues");
assert_eq!(rel::REVIEWS, "reviews");
}
#[test]
fn task_intake_event_has_correct_structure() {
let params = TaskIntakeParams {
branch: "main".to_string(),
parent_hash: None,
source: "github_issue".to_string(),
source_id: "45".to_string(),
source_url: "https://github.com/owner/repo/issues/45".to_string(),
title: "Add user authentication".to_string(),
intent: "implement".to_string(),
labels: vec!["enhancement".to_string()],
priority: "normal".to_string(),
constraints: vec![],
};
let event = new_task_intake_event(¶ms).unwrap();
assert_eq!(event.event_type, "task_intake");
assert!(event.event_id.starts_with("evt_"));
assert_eq!(event.hash.len(), 64);
assert_eq!(event.branch, "main");
assert_eq!(event.payload["source"], "github_issue");
assert_eq!(event.payload["source_id"], "45");
assert_eq!(event.payload["title"], "Add user authentication");
assert_eq!(event.payload["intent"], "implement");
assert_eq!(event.payload["labels"][0], "enhancement");
assert_eq!(event.payload["priority"], "normal");
}
#[test]
fn task_intake_classification() {
use crate::types::{classify_event_type, event_family, event_level};
let (f, l) = classify_event_type("task_intake");
assert_eq!(f, Some(event_family::SIGNAL));
assert_eq!(l, Some(event_level::INFO));
}
#[test]
fn task_intake_taxonomy_auto_set() {
let params = TaskIntakeParams {
branch: "main".to_string(),
parent_hash: Some("abc123".to_string()),
source: "github_issue".to_string(),
source_id: "112".to_string(),
source_url: "https://github.com/owner/repo/issues/112".to_string(),
title: "feat: task intake".to_string(),
intent: "implement".to_string(),
labels: vec![],
priority: "normal".to_string(),
constraints: vec![],
};
let event = new_task_intake_event(¶ms).unwrap();
assert_eq!(event.event_family.as_deref(), Some("signal"));
assert_eq!(event.event_level.as_deref(), Some("info"));
assert_eq!(event.parent_hash.as_deref(), Some("abc123"));
}
#[test]
fn review_bundle_event_fields() {
use crate::bundle::*;
let bundle = ReviewBundle {
bundle_id: "bun_test".into(),
change_summary: ChangeSummary {
files: vec![FileChange {
path: "src/main.rs".into(),
added: 10,
deleted: 3,
}],
total_added: 10,
total_deleted: 3,
diff_ref: "HEAD~1".into(),
},
test_results: TestResults {
passed: 50,
failed: 0,
ignored: 2,
total: 52,
failures: vec![],
command: "cargo test".into(),
},
risk_assessment: RiskAssessment {
level: RiskLevel::Low,
factors: vec![],
},
suggested_action: SuggestedAction::Approve,
suggested_reason: "All tests pass".into(),
};
let params = ReviewBundleParams {
branch: "main".into(),
parent_hash: None,
bundle,
};
let event = new_review_bundle_event(¶ms).unwrap();
assert_eq!(event.event_type, "review_bundle");
assert!(event.event_id.starts_with("evt_"));
assert_eq!(event.hash.len(), 64);
assert_eq!(event.payload["bundle_id"], "bun_test");
assert_eq!(event.payload["change_summary"]["total_added"], 10);
assert_eq!(event.payload["test_results"]["passed"], 50);
assert_eq!(event.payload["risk_assessment"]["level"], "low");
assert_eq!(event.payload["suggested_action"], "approve");
}
#[test]
fn review_bundle_taxonomy() {
use crate::types::{classify_event_type, event_family, event_level};
let (f, l) = classify_event_type("review_bundle");
assert_eq!(f, Some(event_family::GOVERNANCE));
assert_eq!(l, Some(event_level::MILESTONE));
}
#[test]
fn agent_phase_change_event_fields() {
let signals = vec!["branch feat/auth-45 exists".to_string()];
let event = new_agent_phase_change_event(&AgentPhaseChangeParams {
branch: "main",
parent_hash: None,
session_id: "sess-abc",
label: Some("feature-worker"),
from: "research",
to: "implement",
issue: Some(45),
confidence: 0.85,
signals: &signals,
})
.unwrap();
assert_eq!(event.event_type, "agent_phase_change");
assert_eq!(event.payload["session_id"], "sess-abc");
assert_eq!(event.payload["label"], "feature-worker");
assert_eq!(event.payload["from"], "research");
assert_eq!(event.payload["to"], "implement");
assert_eq!(event.payload["issue"], 45);
let conf = event.payload["confidence"].as_f64().unwrap();
assert!((conf - 0.85).abs() < 0.001);
assert_eq!(event.schema_version, SCHEMA_VERSION);
assert_eq!(event.digests[0].value, event.hash);
}
#[test]
fn agent_phase_change_event_minimal() {
let event = new_agent_phase_change_event(&AgentPhaseChangeParams {
branch: "main",
parent_hash: None,
session_id: "sess-xyz",
label: None,
from: "triage",
to: "research",
issue: None,
confidence: 0.6,
signals: &[],
})
.unwrap();
assert_eq!(event.event_type, "agent_phase_change");
assert!(event.payload.get("label").is_none());
assert!(event.payload.get("issue").is_none());
}
#[test]
fn taxonomy_agent_phase_change_is_signal_info() {
let event = new_agent_phase_change_event(&AgentPhaseChangeParams {
branch: "main",
parent_hash: None,
session_id: "sess-test",
label: None,
from: "triage",
to: "research",
issue: None,
confidence: 0.7,
signals: &[],
})
.unwrap();
assert_eq!(event.event_family.as_deref(), Some("signal"));
assert_eq!(event.event_level.as_deref(), Some("info"));
}
#[test]
fn finalize_coerces_nan_to_null() {
let mut event = Event {
event_id: "test".into(),
ts: "2026-01-01T00:00:00Z".into(),
event_type: "note".into(),
branch: "main".into(),
parent_hash: None,
hash: String::new(),
payload: serde_json::json!({"value": f64::NAN}),
refs: Refs::default(),
schema_version: 1,
digests: Vec::new(),
event_family: None,
event_level: None,
};
assert!(
event.payload["value"].is_null(),
"NAN should be coerced to null by json! macro"
);
assert!(finalize_event(&mut event).is_ok());
}
}