diff --git a/crates/edda-aggregate/src/aggregate.rs b/crates/edda-aggregate/src/aggregate.rs index 40fcb0d..00c6924 100644 --- a/crates/edda-aggregate/src/aggregate.rs +++ b/crates/edda-aggregate/src/aggregate.rs @@ -872,6 +872,11 @@ mod tests { value: "sqlite".to_string(), reason: Some("embedded".to_string()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = edda_core::event::new_decision_event("main", None, "system", &decision).unwrap(); diff --git a/crates/edda-aggregate/src/graph.rs b/crates/edda-aggregate/src/graph.rs index 23a28d1..8807094 100644 --- a/crates/edda-aggregate/src/graph.rs +++ b/crates/edda-aggregate/src/graph.rs @@ -101,6 +101,11 @@ mod tests { value: value.to_string(), reason: reason.map(|r| r.to_string()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, } } diff --git a/crates/edda-bridge-claude/src/digest/orchestrate.rs b/crates/edda-bridge-claude/src/digest/orchestrate.rs index 386cba1..3a3a545 100644 --- a/crates/edda-bridge-claude/src/digest/orchestrate.rs +++ b/crates/edda-bridge-claude/src/digest/orchestrate.rs @@ -272,6 +272,11 @@ pub(super) fn harvest_inferred_decisions( value: pkg.to_string(), reason: Some(reason.clone()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let mut event = edda_core::event::new_decision_event(branch, chain_hash.as_deref(), "system", &dp) diff --git a/crates/edda-bridge-claude/src/digest/tests.rs b/crates/edda-bridge-claude/src/digest/tests.rs index a471679..be23482 100644 --- a/crates/edda-bridge-claude/src/digest/tests.rs +++ b/crates/edda-bridge-claude/src/digest/tests.rs @@ -1047,6 +1047,11 @@ fn collect_session_ledger_extras_basic() { value: "jwt".to_string(), reason: Some("stateless".to_string()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let evt = edda_core::event::new_decision_event(&branch, None, "system", &dp).unwrap(); let decision_ts = evt.ts.clone(); diff --git a/crates/edda-cli/src/cmd_bridge.rs b/crates/edda-cli/src/cmd_bridge.rs index e2ed25f..85858c5 100644 --- a/crates/edda-cli/src/cmd_bridge.rs +++ b/crates/edda-cli/src/cmd_bridge.rs @@ -545,6 +545,11 @@ pub fn decide( value: value.to_string(), reason: reason.map(|r| r.to_string()), scope, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let mut event = edda_core::event::new_decision_event(&branch, parent_hash.as_deref(), actor, &dp)?; @@ -991,6 +996,11 @@ fn write_accepted_to_ledger( value: d.value.clone(), reason: d.reason.clone(), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let actor = match d.kind { diff --git a/crates/edda-core/src/decision.rs b/crates/edda-core/src/decision.rs index c5007da..0c8f772 100644 --- a/crates/edda-core/src/decision.rs +++ b/crates/edda-core/src/decision.rs @@ -38,11 +38,35 @@ pub fn extract_decision(payload: &Value) -> Option { .get("scope") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()); + // V10 fields — all Option, gracefully default to None if missing + let authority = d + .get("authority") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let affected_paths: Option> = d + .get("affected_paths") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let tags: Option> = d + .get("tags") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let review_after = d + .get("review_after") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let reversibility = d + .get("reversibility") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); return Some(DecisionPayload { key, value, reason, scope, + authority, + affected_paths, + tags, + review_after, + reversibility, }); } // Text fallback: "key: value — reason" @@ -57,6 +81,11 @@ pub fn extract_decision(payload: &Value) -> Option { value, reason, scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }) } diff --git a/crates/edda-core/src/event.rs b/crates/edda-core/src/event.rs index 3851608..d948657 100644 --- a/crates/edda-core/src/event.rs +++ b/crates/edda-core/src/event.rs @@ -779,6 +779,11 @@ mod tests { 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, }; let event = new_decision_event("main", None, "system", &dp).unwrap(); assert_eq!(event.event_type, "note"); @@ -802,6 +807,11 @@ mod tests { value: "JWT".to_string(), reason: None, scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = new_decision_event("main", None, "system", &dp).unwrap(); assert_eq!(event.payload["decision"]["key"], "auth.method"); @@ -817,6 +827,11 @@ mod tests { value: "REST".to_string(), reason: Some("compatibility".to_string()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = new_decision_event("main", None, "system", &dp).unwrap(); let extracted = crate::decision::extract_decision(&event.payload).unwrap(); diff --git a/crates/edda-core/src/types.rs b/crates/edda-core/src/types.rs index 66b1924..629b9be 100644 --- a/crates/edda-core/src/types.rs +++ b/crates/edda-core/src/types.rs @@ -123,6 +123,21 @@ pub struct DecisionPayload { pub reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, + /// Decision authority: "human", "agent", "system". Default: "human". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authority: Option, + /// Glob patterns for guarded file paths. Default: []. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub affected_paths: Option>, + /// Categorization tags. Default: []. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// ISO-8601 date for scheduled re-evaluation. Default: None. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_after: Option, + /// Reversibility level: "easy", "medium", "hard". Default: "medium". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reversibility: Option, } /// Status of a task brief. @@ -460,6 +475,11 @@ mod tests { 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, }; let json = serde_json::to_string(&dp).expect("serialize"); let decoded: DecisionPayload = serde_json::from_str(&json).expect("deserialize"); @@ -471,6 +491,11 @@ mod tests { value: "JWT".to_string(), reason: None, scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let json2 = serde_json::to_string(&dp_no_reason).expect("serialize"); assert!(!json2.contains("reason"), "None reason should be omitted"); @@ -486,6 +511,11 @@ mod tests { value: "v3".to_string(), reason: Some("breaking change".to_string()), scope: Some(DecisionScope::Shared), + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let json = serde_json::to_string(&dp).expect("serialize"); assert!(json.contains("\"scope\":\"shared\"")); @@ -664,4 +694,46 @@ mod tests { let decoded: TaskBriefIntent = serde_json::from_str(&json).unwrap(); assert_eq!(decoded, TaskBriefIntent::Fix); } + + // ── DecisionPayload V10 fields tests ───────────────────────── + + #[test] + fn test_decision_payload_serde_backward_compat() { + let json = r#"{"key":"db.engine","value":"sqlite","reason":"embedded"}"#; + let dp: DecisionPayload = serde_json::from_str(json).unwrap(); + assert_eq!(dp.key, "db.engine"); + assert_eq!(dp.authority, None); + assert_eq!(dp.affected_paths, None); + assert_eq!(dp.tags, None); + assert_eq!(dp.review_after, None); + assert_eq!(dp.reversibility, None); + } + + #[test] + fn test_decision_payload_serde_with_new_fields() { + let json = r#"{ + "key": "db.engine", + "value": "sqlite", + "reason": "embedded", + "authority": "agent", + "affected_paths": ["crates/edda-ledger/**"], + "tags": ["arch"], + "review_after": "2026-06-01", + "reversibility": "hard" + }"#; + let dp: DecisionPayload = serde_json::from_str(json).unwrap(); + assert_eq!(dp.authority.as_deref(), Some("agent")); + assert_eq!( + dp.affected_paths, + Some(vec!["crates/edda-ledger/**".to_string()]) + ); + assert_eq!(dp.tags, Some(vec!["arch".to_string()])); + assert_eq!(dp.review_after.as_deref(), Some("2026-06-01")); + assert_eq!(dp.reversibility.as_deref(), Some("hard")); + + // Round-trip: serialize and deserialize + let serialized = serde_json::to_string(&dp).unwrap(); + let dp2: DecisionPayload = serde_json::from_str(&serialized).unwrap(); + assert_eq!(dp, dp2); + } } diff --git a/crates/edda-derive/src/context/session.rs b/crates/edda-derive/src/context/session.rs index 65b7728..d571463 100644 --- a/crates/edda-derive/src/context/session.rs +++ b/crates/edda-derive/src/context/session.rs @@ -294,6 +294,7 @@ pub(crate) mod digest_helpers { ) } + #[allow(clippy::too_many_arguments)] pub(crate) fn make_digest_note_with_tasks( branch: &str, session_id: &str, @@ -317,6 +318,7 @@ pub(crate) mod digest_helpers { }) } + #[allow(clippy::too_many_arguments)] pub(crate) fn make_digest_note_full( branch: &str, session_id: &str, diff --git a/crates/edda-ledger/src/sqlite_store.rs b/crates/edda-ledger/src/sqlite_store.rs index 9f7898f..4bfe9ce 100644 --- a/crates/edda-ledger/src/sqlite_store.rs +++ b/crates/edda-ledger/src/sqlite_store.rs @@ -343,6 +343,14 @@ pub struct ExecutionLinked { pub latency_ms: Option, } +/// Map a decision status string to the legacy is_active boolean. +/// +/// `is_active = true` iff status is "active" or "experimental". +/// This enforces CONTRACT COMPAT-01. +fn status_to_is_active(status: &str) -> bool { + matches!(status, "active" | "experimental") +} + /// SQLite-backed storage engine. pub struct SqliteStore { conn: Connection, @@ -1004,7 +1012,7 @@ impl SqliteStore { // Deactivate prior decision with same key on same branch tx.execute( - "UPDATE decisions SET is_active = FALSE + "UPDATE decisions SET is_active = FALSE, status = 'superseded' WHERE key = ?1 AND branch = ?2 AND is_active = TRUE", params![key, event.branch], )?; @@ -1013,10 +1021,31 @@ impl SqliteStore { .scope .unwrap_or(edda_core::types::DecisionScope::Local) .to_string(); + + // Read new V10 fields from payload, with safe defaults + let status = "active"; + let is_active = status_to_is_active(status); + let authority = dp.authority.as_deref().unwrap_or("human"); + let affected_paths = dp + .affected_paths + .as_ref() + .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())) + .unwrap_or_else(|| "[]".to_string()); + let tags = dp + .tags + .as_ref() + .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())) + .unwrap_or_else(|| "[]".to_string()); + let review_after = dp.review_after.as_deref(); + let reversibility = dp.reversibility.as_deref().unwrap_or("medium"); + tx.execute( "INSERT INTO decisions - (event_id, key, value, reason, domain, branch, supersedes_id, is_active, scope) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, TRUE, ?8)", + (event_id, key, value, reason, domain, branch, supersedes_id, + is_active, scope, status, authority, affected_paths, tags, + review_after, reversibility) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, + ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", params![ event.event_id, key, @@ -1025,7 +1054,14 @@ impl SqliteStore { domain, event.branch, supersedes_id, - scope_str + is_active, + scope_str, + status, + authority, + affected_paths, + tags, + review_after, + reversibility, ], )?; } @@ -1718,18 +1754,19 @@ impl SqliteStore { // If active, deactivate prior local decision with same key if p.is_active { tx.execute( - "UPDATE decisions SET is_active = FALSE + "UPDATE decisions SET is_active = FALSE, status = 'superseded' WHERE key = ?1 AND branch = ?2 AND is_active = TRUE AND source_project_id IS NULL", params![p.key, p.event.branch], )?; } + let status = if p.is_active { "active" } else { "superseded" }; tx.execute( "INSERT INTO decisions (event_id, key, value, reason, domain, branch, supersedes_id, is_active, - scope, source_project_id, source_event_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, ?10)", + scope, source_project_id, source_event_id, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, ?10, ?11)", params![ p.event.event_id, p.key, @@ -1741,6 +1778,7 @@ impl SqliteStore { p.scope, p.source_project_id, p.source_event_id, + status, ], )?; @@ -3877,6 +3915,7 @@ mod tests { latency_ms: u64, } + #[allow(clippy::too_many_arguments)] fn make_execution_event_with_decision_ref( branch: &str, event_id: &str, @@ -5629,4 +5668,206 @@ mod tests { drop(store); let _ = std::fs::remove_dir_all(&dir); } + + // ── B1: Status Sync tests ────────────────────────────────────── + + #[test] + fn test_status_to_is_active() { + assert!(status_to_is_active("active")); + assert!(status_to_is_active("experimental")); + assert!(!status_to_is_active("proposed")); + assert!(!status_to_is_active("deprecated")); + assert!(!status_to_is_active("superseded")); + } + + #[test] + fn test_status_is_active_sync_on_insert() { + let (dir, store) = tmp_db(); + + let dp = edda_core::types::DecisionPayload { + key: "sync.test".to_string(), + value: "v1".to_string(), + reason: Some("testing sync".to_string()), + scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, + }; + let event = edda_core::event::new_decision_event("main", None, "system", &dp).unwrap(); + store.append_event(&event).unwrap(); + + // Verify status and is_active agree + let (status, is_active): (String, bool) = store + .conn + .query_row( + "SELECT status, is_active FROM decisions WHERE key = 'sync.test'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(status, "active"); + assert!(is_active); + + // Supersede with a new value + let dp2 = edda_core::types::DecisionPayload { + key: "sync.test".to_string(), + value: "v2".to_string(), + reason: Some("supersede".to_string()), + scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, + }; + let mut event2 = + edda_core::event::new_decision_event("main", Some(&event.hash), "system", &dp2) + .unwrap(); + event2.refs.provenance.push(edda_core::types::Provenance { + target: event.event_id.clone(), + rel: "supersedes".to_string(), + note: None, + }); + edda_core::event::finalize_event(&mut event2).unwrap(); + store.append_event(&event2).unwrap(); + + // Old decision: is_active=false, status=superseded + let (old_status, old_active): (String, bool) = store + .conn + .query_row( + "SELECT status, is_active FROM decisions WHERE key = 'sync.test' AND value = 'v1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(old_status, "superseded"); + assert!(!old_active); + + // New decision: is_active=true, status=active + let (new_status, new_active): (String, bool) = store + .conn + .query_row( + "SELECT status, is_active FROM decisions WHERE key = 'sync.test' AND value = 'v2'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(new_status, "active"); + assert!(new_active); + + // COMPAT-01 full table check + let violations: i64 = store + .conn + .query_row( + "SELECT COUNT(*) FROM decisions + WHERE (is_active = 1 AND status NOT IN ('active', 'experimental')) + OR (is_active = 0 AND status IN ('active', 'experimental'))", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(violations, 0, "COMPAT-01 violated"); + + drop(store); + let _ = std::fs::remove_dir_all(&dir); + } + + // ── B2: DecisionPayload new fields tests ─────────────────────── + + #[test] + fn test_decision_payload_new_fields_roundtrip() { + let (dir, store) = tmp_db(); + + let dp = edda_core::types::DecisionPayload { + key: "db.engine".to_string(), + value: "sqlite".to_string(), + reason: Some("embedded".to_string()), + scope: None, + authority: Some("human".to_string()), + affected_paths: Some(vec![ + "crates/edda-ledger/**".to_string(), + "crates/edda-store/**".to_string(), + ]), + tags: Some(vec!["architecture".to_string(), "storage".to_string()]), + review_after: Some("2026-06-01".to_string()), + reversibility: Some("hard".to_string()), + }; + let event = edda_core::event::new_decision_event("main", None, "system", &dp).unwrap(); + store.append_event(&event).unwrap(); + + let row = store + .conn + .query_row( + "SELECT authority, affected_paths, tags, review_after, reversibility + FROM decisions WHERE key = 'db.engine'", + [], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, String>(4)?, + )) + }, + ) + .unwrap(); + + assert_eq!(row.0, "human"); + assert_eq!(row.1, r#"["crates/edda-ledger/**","crates/edda-store/**"]"#); + assert_eq!(row.2, r#"["architecture","storage"]"#); + assert_eq!(row.3.as_deref(), Some("2026-06-01")); + assert_eq!(row.4, "hard"); + + drop(store); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_decision_payload_defaults_when_none() { + let (dir, store) = tmp_db(); + + let dp = edda_core::types::DecisionPayload { + key: "default.test".to_string(), + value: "val".to_string(), + reason: None, + scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, + }; + let event = edda_core::event::new_decision_event("main", None, "system", &dp).unwrap(); + store.append_event(&event).unwrap(); + + let row = store + .conn + .query_row( + "SELECT authority, affected_paths, tags, review_after, reversibility + FROM decisions WHERE key = 'default.test'", + [], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, String>(4)?, + )) + }, + ) + .unwrap(); + + assert_eq!(row.0, "human"); // authority default + assert_eq!(row.1, "[]"); // affected_paths default + assert_eq!(row.2, "[]"); // tags default + assert_eq!(row.3, None); // review_after default + assert_eq!(row.4, "medium"); // reversibility default + + drop(store); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/edda-ledger/src/sync.rs b/crates/edda-ledger/src/sync.rs index 469215f..ee15a0f 100644 --- a/crates/edda-ledger/src/sync.rs +++ b/crates/edda-ledger/src/sync.rs @@ -263,6 +263,11 @@ mod tests { value: value.to_string(), reason: Some(reason.to_string()), scope: Some(DecisionScope::Shared), + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = edda_core::event::new_decision_event("main", None, "system", &dp).unwrap(); ledger.append_event(&event).unwrap(); @@ -274,6 +279,11 @@ mod tests { value: value.to_string(), reason: None, scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = edda_core::event::new_decision_event("main", None, "system", &dp).unwrap(); ledger.append_event(&event).unwrap(); diff --git a/crates/edda-mcp/src/lib.rs b/crates/edda-mcp/src/lib.rs index a07157e..1250ec2 100644 --- a/crates/edda-mcp/src/lib.rs +++ b/crates/edda-mcp/src/lib.rs @@ -219,6 +219,11 @@ impl EddaServer { value: value.to_string(), reason: params.reason.clone(), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let mut event = new_decision_event(&branch, parent_hash.as_deref(), "system", &dp) .map_err(to_mcp_err)?; diff --git a/crates/edda-serve/src/lib.rs b/crates/edda-serve/src/lib.rs index dd5d8ad..d138840 100644 --- a/crates/edda-serve/src/lib.rs +++ b/crates/edda-serve/src/lib.rs @@ -1678,6 +1678,11 @@ async fn post_decide( value: value.to_string(), reason: body.reason, scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let mut event = new_decision_event(&branch, parent_hash.as_deref(), "system", &dp)?; @@ -3663,6 +3668,7 @@ async fn get_event_stream( // ── Tests ── #[cfg(test)] +#[allow(clippy::await_holding_lock, clippy::len_zero)] mod tests { use super::*; use axum::body::Body; @@ -5317,6 +5323,11 @@ actors: value: "test_val".into(), reason: Some("testing".into()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let decision = edda_core::event::new_decision_event("main", None, "system", &dp).unwrap(); ledger.append_event(&decision).unwrap(); @@ -6232,6 +6243,11 @@ actors: value: "sqlite".into(), reason: Some("embedded".into()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = new_decision_event("main", parent_hash.as_deref(), "user", &dp).unwrap(); ledger.append_event(&event).unwrap(); @@ -6372,6 +6388,11 @@ actors: value: "redis".into(), reason: Some("fast".into()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = new_decision_event("main", parent_hash.as_deref(), "user", &dp).unwrap(); ledger.append_event(&event).unwrap(); @@ -6420,6 +6441,11 @@ actors: value: "daytime_revenue_shield".into(), reason: Some("avoid aggressive daytime markdowns".into()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = new_decision_event("main", parent_hash.as_deref(), "user", &dp).unwrap(); ledger.append_event(&event).unwrap(); @@ -6458,6 +6484,11 @@ actors: value: "daytime_revenue_shield".into(), reason: Some("avoid aggressive daytime markdowns".into()), scope: None, + authority: None, + affected_paths: None, + tags: None, + review_after: None, + reversibility: None, }; let event = new_decision_event("main", parent_hash.as_deref(), "user", &dp).unwrap(); ledger.append_event(&event).unwrap();