From 9cf4a66d770c09b7744c4de4f29d827832462c87 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:48:51 -0500 Subject: [PATCH 1/7] feat(ward): migrate Phase 5 audit schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 4 +- crates/coven-cli/Cargo.toml | 6 +- crates/coven-cli/src/api.rs | 51 +++- crates/coven-cli/src/store.rs | 369 ++++++++++++++++++++++++++- crates/coven-cli/src/threads_gate.rs | 1 + 5 files changed, 416 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02ffd14d..ebe07406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,8 +679,8 @@ dependencies = [ [[package]] name = "coven-threads-core" -version = "0.1.0" -source = "git+https://github.com/OpenCoven/coven-threads?tag=v0.1.3#5b4a51acfa7818cfd53c55998cd4a0bc733fc2fd" +version = "0.2.0" +source = "git+https://github.com/OpenCoven/coven-threads?rev=7da030d#7da030d63468e87c986e74d12e589f4c6283e8fe" dependencies = [ "blake3", "serde", diff --git a/crates/coven-cli/Cargo.toml b/crates/coven-cli/Cargo.toml index ba485681..c8bb3462 100644 --- a/crates/coven-cli/Cargo.toml +++ b/crates/coven-cli/Cargo.toml @@ -23,10 +23,8 @@ coven-runtime-spec = { git = "https://github.com/OpenCoven/coven-runtimes", tag # The authority-boundary gate layer for familiar protected surfaces # (PHASE-0-DESIGN.md in OpenCoven/coven-threads; RFC-0001 §5). The daemon is # the only caller: familiar-controlled processes never reach this crate. -# Pinned to a release tag like coven-runtime-spec above. NOTE: repo is private -# until the visibility flip (Val decision); local builds authenticate via the -# git CLI (.cargo/config.toml `net.git-fetch-with-cli`). -coven-threads-core = { git = "https://github.com/OpenCoven/coven-threads", tag = "v0.1.3", version = "0.1.0" } +# Pinned to the reviewed Phase 5 core revision until its 0.2 release tag lands. +coven-threads-core = { git = "https://github.com/OpenCoven/coven-threads", rev = "7da030d", version = "0.2.0" } blake3 = "1" time = { version = "0.3", features = ["formatting"] } crossterm = "0.29" diff --git a/crates/coven-cli/src/api.rs b/crates/coven-cli/src/api.rs index 3d4751be..7a4d15cf 100644 --- a/crates/coven-cli/src/api.rs +++ b/crates/coven-cli/src/api.rs @@ -3099,6 +3099,7 @@ fn decide_threads_proposal( approver: Some(&pending.writer), files_touched: &targets, decision: "rejected", + approval_rationale: None, }, )?; fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; @@ -3119,6 +3120,7 @@ fn decide_threads_proposal( surface: coven_threads_core::SurfaceId::new(target.clone()), writer: pending.writer.clone(), channel: coven_threads_core::Channel::Mutation, + identity_context: None, }; let verdict = coven_threads_core::validate_fail_closed(&state.weave, &request); crate::threads_gate::append_audit_row( @@ -3179,6 +3181,7 @@ fn decide_threads_proposal( approver: Some(&pending.writer), files_touched: &targets, decision: "approved", + approval_rationale: note.as_deref(), }, )?; fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; @@ -3266,13 +3269,14 @@ fn append_proposal_refusal_audit( append_proposal_decision_audit( conn, ProposalDecisionAudit { - event_type: coven_threads_core::AuditEventType::ProposalRejected, + event_type: coven_threads_core::AuditEventType::ValidationVerdict, proposal_id, familiar_id, weave_hash, approver: Some(approver), files_touched, decision: "proposal-revalidation-failed", + approval_rationale: None, }, ) } @@ -3285,6 +3289,7 @@ struct ProposalDecisionAudit<'a> { approver: Option<&'a coven_threads_core::WriterId>, files_touched: &'a [String], decision: &'a str, + approval_rationale: Option<&'a str>, } fn append_proposal_decision_audit( @@ -3292,14 +3297,24 @@ fn append_proposal_decision_audit( audit: ProposalDecisionAudit<'_>, ) -> Result<()> { let files_touched = serde_json::to_string(audit.files_touched)?; + let detail = match audit.event_type { + coven_threads_core::AuditEventType::ProposalApproved => Some(serde_json::to_string( + &coven_threads_core::ProposalApprovalAuditDetail { + approval_path_label: "human_review".to_string(), + rationale: audit.approval_rationale.map(str::to_string), + window_close: None, + }, + )?), + _ => None, + }; let now = time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?; conn.execute( "INSERT INTO ward_audit ( event_type, proposal_id, familiar_id, ward_version, ward_hash, - tier, decision, approver, diff_hash, files_touched, channel, - thread_id, submitted_at, decided_at - ) VALUES (?1, ?2, ?3, NULL, ?4, NULL, ?5, ?6, NULL, ?7, ?8, NULL, ?9, ?9)", + tier, decision, approver, diff_hash, detail, files_touched, + channel, thread_id, submitted_at, decided_at + ) VALUES (?1, ?2, ?3, NULL, ?4, NULL, ?5, ?6, NULL, ?7, ?8, ?9, NULL, ?10, ?10)", rusqlite::params![ audit.event_type.tag(), audit.proposal_id, @@ -3307,6 +3322,7 @@ fn append_proposal_decision_audit( audit.weave_hash, audit.decision, audit.approver.map(|w| w.as_str().to_string()), + detail, files_touched, format!("{:?}", coven_threads_core::Channel::Mutation).to_lowercase(), now, @@ -7125,12 +7141,21 @@ tier = 0 ); assert!(!pending.exists(), "approved proposal must be removed"); let conn = store::open_store(&home.join("coven.sqlite3"))?; - let event_type: String = conn.query_row( - "SELECT event_type FROM ward_audit WHERE proposal_id = ?1 ORDER BY id DESC LIMIT 1", + let (event_type, detail): (String, String) = conn.query_row( + "SELECT event_type, detail + FROM ward_audit + WHERE proposal_id = ?1 + ORDER BY id DESC + LIMIT 1", [&proposal_id], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), )?; assert_eq!(event_type, "proposal_approved"); + let detail: coven_threads_core::ProposalApprovalAuditDetail = + serde_json::from_str(&detail)?; + assert_eq!(detail.approval_path_label, "human_review"); + assert_eq!(detail.rationale.as_deref(), Some("principal reviewed")); + assert_eq!(detail.window_close, None); Ok(()) } @@ -7274,6 +7299,18 @@ tier = 0 |row| row.get(0), )?; assert_eq!(approved_count, 0); + let terminal_count: i64 = conn.query_row( + "SELECT COUNT(*) + FROM ward_audit + WHERE proposal_id = ?1 + AND event_type IN ('proposal_approved', 'proposal_rejected', 'proposal_vetoed')", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!( + terminal_count, 0, + "a retryable refusal must not close the proposal lifecycle" + ); std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; let retry = handle_request_with_body( diff --git a/crates/coven-cli/src/store.rs b/crates/coven-cli/src/store.rs index 99695099..5946d427 100644 --- a/crates/coven-cli/src/store.rs +++ b/crates/coven-cli/src/store.rs @@ -241,6 +241,91 @@ pub struct EventsQueryOptions { pub limit: Option, } +fn load_ward_audit_schema_sql(conn: &Connection) -> Result> { + conn.query_row( + "SELECT group_concat(sql, char(10)) + FROM ( + SELECT sql + FROM sqlite_master + WHERE sql IS NOT NULL + AND ( + (type = 'table' AND name = 'ward_audit') + OR (type = 'trigger' AND tbl_name = 'ward_audit') + ) + ORDER BY type, name + )", + [], + |row| row.get(0), + ) + .context("failed to inspect ward_audit schema") +} + +fn load_ward_audit_component_version(conn: &Connection) -> Result> { + let metadata_exists = conn + .query_row( + "SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = 'ward_schema_meta'", + [], + |row| row.get::<_, i64>(0), + ) + .optional() + .context("failed to inspect ward_schema_meta")? + .is_some(); + if !metadata_exists { + return Ok(None); + } + + conn.query_row( + "SELECT version + FROM ward_schema_meta + WHERE component = 'ward_audit'", + [], + |row| row.get(0), + ) + .optional() + .context("failed to read ward_audit component version") +} + +fn ensure_ward_audit_schema(conn: &Connection) -> Result<()> { + use coven_threads_core::{ + ward_audit_migration_sql, ward_audit_schema_action, WardAuditSchemaAction, + WARD_AUDIT_SCHEMA_SQL, WARD_AUDIT_SCHEMA_VERSION, WARD_AUDIT_STAMP_V020_SQL, + }; + + let schema_sql = load_ward_audit_schema_sql(conn)?; + let component_version = load_ward_audit_component_version(conn)?; + match ward_audit_schema_action(schema_sql.as_deref(), component_version) { + WardAuditSchemaAction::InitializeFresh => { + conn.execute_batch(WARD_AUDIT_SCHEMA_SQL) + .context("failed to initialize ward_audit schema")?; + conn.execute_batch(WARD_AUDIT_STAMP_V020_SQL) + .context("failed to stamp ward_audit schema version")?; + } + WardAuditSchemaAction::MigrateLegacyWithoutDetail => { + conn.execute_batch(&ward_audit_migration_sql(false)) + .context("failed to migrate pre-detail ward_audit schema")?; + } + WardAuditSchemaAction::MigrateLegacyWithDetail => { + conn.execute_batch(&ward_audit_migration_sql(true)) + .context("failed to migrate ward_audit schema with detail")?; + } + WardAuditSchemaAction::StampCurrent => { + conn.execute_batch(WARD_AUDIT_STAMP_V020_SQL) + .context("failed to stamp current ward_audit schema")?; + } + WardAuditSchemaAction::UnsupportedNewerVersion => { + anyhow::bail!( + "unsupported ward_audit schema version {}; daemon supports at most {}", + component_version.unwrap_or_default(), + WARD_AUDIT_SCHEMA_VERSION + ); + } + WardAuditSchemaAction::None => {} + } + Ok(()) +} + pub fn open_store(path: &Path) -> Result { if let Some(parent) = path .parent() @@ -482,8 +567,7 @@ pub fn open_store(path: &Path) -> Result { // The coven-threads gate layer's daemon-owned tables: the append-only // ward.audit ledger (single audit store — PHASE-0-DESIGN §3.4, RFC-0001 // §5.6) and the per-familiar surface baseline manifest. Both idempotent. - conn.execute_batch(coven_threads_core::WARD_AUDIT_SCHEMA_SQL) - .context("failed to initialize ward_audit schema")?; + ensure_ward_audit_schema(&conn)?; conn.execute_batch(crate::threads_gate::WARD_MANIFEST_SCHEMA_SQL) .context("failed to initialize ward_manifest schema")?; ensure_exit_code_column(&conn)?; @@ -2715,6 +2799,287 @@ pub fn artifact_payload(record: &SensitiveArtifactRecord) -> EncryptedPayload { mod tests { use super::*; + const LEGACY_WARD_AUDIT_WITHOUT_DETAIL_SQL: &str = r#" + CREATE TABLE ward_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + proposal_id TEXT, + familiar_id TEXT NOT NULL, + ward_version TEXT, + ward_hash BLOB NOT NULL, + tier TEXT, + decision TEXT NOT NULL, + approver TEXT, + diff_hash BLOB, + files_touched TEXT NOT NULL, + channel TEXT, + thread_id TEXT, + submitted_at TEXT NOT NULL, + decided_at TEXT NOT NULL, + recorded_at TEXT NOT NULL + ); + "#; + + const LEGACY_WARD_AUDIT_WITH_DETAIL_SQL: &str = r#" + CREATE TABLE ward_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + proposal_id TEXT, + familiar_id TEXT NOT NULL, + ward_version TEXT, + ward_hash BLOB NOT NULL, + tier TEXT, + decision TEXT NOT NULL, + approver TEXT, + diff_hash BLOB, + detail TEXT, + files_touched TEXT NOT NULL, + channel TEXT, + thread_id TEXT, + submitted_at TEXT NOT NULL, + decided_at TEXT NOT NULL, + recorded_at TEXT NOT NULL + ); + "#; + + #[derive(Debug, PartialEq, Eq)] + struct LegacyWardAuditRow { + id: i64, + event_type: String, + proposal_id: Option, + familiar_id: String, + ward_version: Option, + ward_hash: Vec, + tier: Option, + decision: String, + approver: Option, + diff_hash: Option>, + detail: Option, + files_touched: String, + channel: Option, + thread_id: Option, + submitted_at: String, + decided_at: String, + recorded_at: String, + } + + fn ward_audit_component_version(conn: &Connection) -> Result { + conn.query_row( + "SELECT version FROM ward_schema_meta WHERE component = 'ward_audit'", + [], + |row| row.get(0), + ) + .context("ward_audit component version is missing") + } + + fn insert_legacy_ward_audit_row(conn: &Connection, detail: Option<&str>) -> Result<()> { + if let Some(detail) = detail { + conn.execute( + "INSERT INTO ward_audit ( + id, event_type, proposal_id, familiar_id, ward_version, ward_hash, + tier, decision, approver, diff_hash, detail, files_touched, + channel, thread_id, submitted_at, decided_at, recorded_at + ) VALUES ( + 7, 'proposal_submitted', NULL, 'sage', 'legacy-v1', ?1, + 'reviewed', 'pending', NULL, ?2, ?4, ?3, + 'mutation', 'thread-legacy', '2026-07-01T00:00:00Z', + '2026-07-01T00:00:01Z', '2026-07-01T00:00:02Z' + )", + params![ + vec![0x11_u8; 32], + vec![0x22_u8; 32], + r#"["SOUL.md"]"#, + detail + ], + )?; + } else { + conn.execute( + "INSERT INTO ward_audit ( + id, event_type, proposal_id, familiar_id, ward_version, ward_hash, + tier, decision, approver, diff_hash, files_touched, + channel, thread_id, submitted_at, decided_at, recorded_at + ) VALUES ( + 7, 'proposal_submitted', NULL, 'sage', 'legacy-v1', ?1, + 'reviewed', 'pending', NULL, ?2, ?3, + 'mutation', 'thread-legacy', '2026-07-01T00:00:00Z', + '2026-07-01T00:00:01Z', '2026-07-01T00:00:02Z' + )", + params![vec![0x11_u8; 32], vec![0x22_u8; 32], r#"["SOUL.md"]"#], + )?; + } + Ok(()) + } + + fn assert_legacy_ward_audit_row( + conn: &Connection, + expected_detail: Option<&str>, + ) -> Result<()> { + let row = conn.query_row( + "SELECT id, event_type, proposal_id, familiar_id, ward_version, ward_hash, + tier, decision, approver, diff_hash, detail, files_touched, + channel, thread_id, submitted_at, decided_at, recorded_at + FROM ward_audit WHERE id = 7", + [], + |row| { + Ok(LegacyWardAuditRow { + id: row.get(0)?, + event_type: row.get(1)?, + proposal_id: row.get(2)?, + familiar_id: row.get(3)?, + ward_version: row.get(4)?, + ward_hash: row.get(5)?, + tier: row.get(6)?, + decision: row.get(7)?, + approver: row.get(8)?, + diff_hash: row.get(9)?, + detail: row.get(10)?, + files_touched: row.get(11)?, + channel: row.get(12)?, + thread_id: row.get(13)?, + submitted_at: row.get(14)?, + decided_at: row.get(15)?, + recorded_at: row.get(16)?, + }) + }, + )?; + assert_eq!( + row, + LegacyWardAuditRow { + id: 7, + event_type: "proposal_submitted".to_string(), + proposal_id: None, + familiar_id: "sage".to_string(), + ward_version: Some("legacy-v1".to_string()), + ward_hash: vec![0x11; 32], + tier: Some("reviewed".to_string()), + decision: "pending".to_string(), + approver: None, + diff_hash: Some(vec![0x22; 32]), + detail: expected_detail.map(str::to_string), + files_touched: r#"["SOUL.md"]"#.to_string(), + channel: Some("mutation".to_string()), + thread_id: Some("thread-legacy".to_string()), + submitted_at: "2026-07-01T00:00:00Z".to_string(), + decided_at: "2026-07-01T00:00:01Z".to_string(), + recorded_at: "2026-07-01T00:00:02Z".to_string(), + } + ); + Ok(()) + } + + #[test] + fn ward_audit_fresh_store_is_stamped_at_current_component_version() -> Result<()> { + let temp = tempfile::tempdir()?; + let conn = open_store(&temp.path().join("coven.db"))?; + + assert_eq!( + ward_audit_component_version(&conn)?, + coven_threads_core::WARD_AUDIT_SCHEMA_VERSION + ); + Ok(()) + } + + #[test] + fn ward_audit_legacy_schema_without_detail_migrates_without_losing_history() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("coven.db"); + let conn = Connection::open(&path)?; + conn.execute_batch(LEGACY_WARD_AUDIT_WITHOUT_DETAIL_SQL)?; + insert_legacy_ward_audit_row(&conn, None)?; + drop(conn); + + let conn = open_store(&path)?; + + assert_legacy_ward_audit_row(&conn, None)?; + assert_eq!( + ward_audit_component_version(&conn)?, + coven_threads_core::WARD_AUDIT_SCHEMA_VERSION + ); + Ok(()) + } + + #[test] + fn ward_audit_legacy_schema_with_detail_preserves_detail_payload() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("coven.db"); + let conn = Connection::open(&path)?; + conn.execute_batch(LEGACY_WARD_AUDIT_WITH_DETAIL_SQL)?; + let detail = r#"{"legacy":true}"#; + insert_legacy_ward_audit_row(&conn, Some(detail))?; + drop(conn); + + let conn = open_store(&path)?; + + assert_legacy_ward_audit_row(&conn, Some(detail))?; + assert_eq!( + ward_audit_component_version(&conn)?, + coven_threads_core::WARD_AUDIT_SCHEMA_VERSION + ); + Ok(()) + } + + #[test] + fn ward_audit_current_unversioned_schema_is_stamped_without_rebuild() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("coven.db"); + let conn = open_store(&path)?; + conn.execute( + "DELETE FROM ward_schema_meta WHERE component = 'ward_audit'", + [], + )?; + drop(conn); + + let conn = open_store(&path)?; + + assert_eq!( + ward_audit_component_version(&conn)?, + coven_threads_core::WARD_AUDIT_SCHEMA_VERSION + ); + Ok(()) + } + + #[test] + fn ward_audit_current_version_is_idempotent_across_reopen() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("coven.db"); + drop(open_store(&path)?); + + let conn = open_store(&path)?; + + assert_eq!( + ward_audit_component_version(&conn)?, + coven_threads_core::WARD_AUDIT_SCHEMA_VERSION + ); + Ok(()) + } + + #[test] + fn ward_audit_newer_component_version_fails_closed() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("coven.db"); + let conn = Connection::open(&path)?; + conn.execute_batch(LEGACY_WARD_AUDIT_WITH_DETAIL_SQL)?; + conn.execute_batch( + "CREATE TABLE ward_schema_meta ( + component TEXT PRIMARY KEY NOT NULL, + version INTEGER NOT NULL + ); + INSERT INTO ward_schema_meta (component, version) + VALUES ('ward_audit', 21);", + )?; + drop(conn); + + let error = open_store(&path).expect_err("newer Ward schema must fail closed"); + + assert!( + error + .to_string() + .contains("unsupported ward_audit schema version"), + "unexpected error: {error:#}" + ); + Ok(()) + } + #[test] fn inserts_and_lists_sessions() -> Result<()> { let temp_dir = tempfile::tempdir()?; diff --git a/crates/coven-cli/src/threads_gate.rs b/crates/coven-cli/src/threads_gate.rs index 2ff41e31..d33f0b8a 100644 --- a/crates/coven-cli/src/threads_gate.rs +++ b/crates/coven-cli/src/threads_gate.rs @@ -186,6 +186,7 @@ pub fn gate_protected_edits(conn: &Connection, req: &GateRequest<'_>) -> Result< surface: threads::SurfaceId::new(target.clone()), writer: request_writer.clone(), channel: threads::Channel::Mutation, + identity_context: None, }; let verdict = threads::validate_fail_closed(&weave, &request); append_audit_row( From d6ba41db79800c1fc392ca32b242f0d446695878 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:23:05 -0500 Subject: [PATCH 2/7] feat(ward): validate Phase 5 proposal envelopes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/coven-cli/src/api.rs | 215 ++++++- crates/coven-cli/src/main.rs | 1 + crates/coven-cli/src/proposal_scheduler.rs | 708 +++++++++++++++++++++ 3 files changed, 909 insertions(+), 15 deletions(-) create mode 100644 crates/coven-cli/src/proposal_scheduler.rs diff --git a/crates/coven-cli/src/api.rs b/crates/coven-cli/src/api.rs index 7a4d15cf..8ba699ba 100644 --- a/crates/coven-cli/src/api.rs +++ b/crates/coven-cli/src/api.rs @@ -2843,23 +2843,63 @@ fn threads_proposals_response(coven_home: &Path, id: Option<&str>) -> Result = - fs::read_to_string(&path).ok().and_then(|raw| { - let value: Value = serde_json::from_str(&raw).ok()?; - let proposal = serde_json::from_str(&raw).ok()?; - Some((proposal, value)) - }); - let Some((proposal, raw_value)) = parsed else { + let Some(raw) = fs::read_to_string(&path).ok() else { proposals.push(json!({ "degraded": { "file": file_name, "reason": "proposal-unparseable" }, })); continue; }; - let review_kind = raw_value - .get("reviewKind") - .and_then(Value::as_str) - .unwrap_or("authority") - .to_string(); + let Some(raw_value) = serde_json::from_str::(&raw).ok() else { + proposals.push(json!({ + "degraded": { "file": file_name, "reason": "proposal-unparseable" }, + })); + continue; + }; + let phase5_shape = [ + "schema", + "pending", + "classification", + "materialized_diff", + "region_evidence", + "lifecycle", + "veto_deadline", + "earliest_close", + ] + .iter() + .any(|field| raw_value.get(field).is_some()); + let scheduled = if phase5_shape { + match serde_json::from_value::( + raw_value.clone(), + ) { + Ok(scheduled) => Some(scheduled), + Err(_) => { + proposals.push(json!({ + "degraded": { "file": file_name, "reason": "proposal-unparseable" }, + })); + continue; + } + } + } else { + None + }; + let legacy = if scheduled.is_none() { + match serde_json::from_value::(raw_value.clone()) { + Ok(proposal) => Some(proposal), + Err(_) => { + proposals.push(json!({ + "degraded": { "file": file_name, "reason": "proposal-unparseable" }, + })); + continue; + } + } + } else { + None + }; + let proposal = scheduled + .as_ref() + .map(crate::proposal_scheduler::ScheduledProposal::pending) + .or(legacy.as_ref()) + .expect("scheduled or legacy proposal parsed"); let Some(familiar_id) = human_familiar_id_for_weave(coven_home, proposal.familiar_id)? else { // A proposal whose familiar vanished from familiars.toml cannot @@ -2875,14 +2915,58 @@ fn threads_proposals_response(coven_home: &Path, id: Option<&str>) -> Result = scheduled + .classification() + .affected_regions + .iter() + .map(coven_threads_core::SurfaceRegionId::as_str) + .collect(); + let view = view + .as_object_mut() + .expect("proposal view is always a JSON object"); + view.insert( + "approvalPath".to_string(), + serde_json::to_value(approval_path)?, + ); + let lifecycle = serde_json::to_value(scheduled.lifecycle())?; + view.insert( + "lifecycle".to_string(), + lifecycle.get("state").cloned().unwrap_or(Value::Null), + ); + if let Some(reason) = lifecycle.get("reason") { + view.insert("blockedReason".to_string(), reason.clone()); + } + view.insert( + "earliestClose".to_string(), + json!(scheduled + .earliest_close() + .and_then(|value| value.format(&format).ok())), + ); + view.insert("affectedRegions".to_string(), json!(affected_regions)); + } else { + let review_kind = raw_value + .get("reviewKind") + .and_then(Value::as_str) + .unwrap_or("authority"); + view.as_object_mut() + .expect("proposal view is always a JSON object") + .insert("reviewKind".to_string(), json!(review_kind)); + } + proposals.push(view); } match id { @@ -6576,6 +6660,107 @@ tier = 2 Ok(()) } + #[test] + fn threads_proposals_renders_validated_phase5_scheduler_state() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + seed_warded_familiar(home)?; + let proposal_id = coven_threads_core::ProposalId::new(); + let familiar_id = crate::threads_gate::familiar_weave_id("sage"); + let surface = coven_threads_core::SurfaceId::new("TOOLS.md"); + let staged_at = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)?; + let pending = coven_threads_core::PendingProposal { + id: proposal_id, + familiar_id, + writer: coven_threads_core::WriterId::new("principal:fpr-val"), + channel: coven_threads_core::Channel::Mutation, + thread_id: coven_threads_core::ThreadId::new(), + fray: coven_threads_core::FrayOrSnap::Frayed { + strand: None, + channel: coven_threads_core::Channel::Mutation, + reason: coven_threads_core::FrayReason::Other("phase-5 fixture".to_string()), + }, + edits: vec![coven_threads_core::StagedEdit { + surface: surface.clone(), + contents: coven_threads_core::StagedContents::from_bytes(b"tweak"), + }], + staged_at, + }; + let diff = + coven_threads_core::MaterializedDiff::try_new(vec![coven_threads_core::SurfaceDiff { + surface: surface.clone(), + before: None, + after: Some(b"tweak".to_vec()), + }]) + .map_err(anyhow::Error::msg)?; + let evidence = + coven_threads_core::SurfaceRegionRegistry::default_registry().classify_all(&diff); + let classification = coven_threads_core::ProposalClassification { + proposal_id, + familiar_id, + channel: coven_threads_core::Channel::Mutation, + affected_surfaces: vec![surface], + affected_regions: evidence.iter().map(|item| item.region_id.clone()).collect(), + path_tier_floor: 1, + approval_path: coven_threads_core::ApprovalPath::FamiliarCoherence { + veto: coven_threads_core::VetoWindow::new( + std::time::Duration::from_secs(300), + std::time::Duration::from_secs(60), + ), + }, + evidence_replay_hash: coven_threads_core::evidence_replay_hash(&diff, &evidence), + classified_at: staged_at, + }; + let scheduled = + crate::proposal_scheduler::ScheduledProposal::try_new(pending, classification, diff)?; + let pending_dir = home.join("pending"); + std::fs::create_dir_all(&pending_dir)?; + std::fs::write( + pending_dir.join(format!("{familiar_id}-{proposal_id}.json")), + serde_json::to_vec_pretty(&scheduled)?, + )?; + + let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + + assert_eq!(response.status, 200, "got {}", response.body); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + let listed = &body["proposals"][0]; + assert_eq!(listed["proposalId"], proposal_id.to_string()); + assert_eq!(listed["familiarId"], "sage"); + assert_eq!(listed["approvalPath"]["variant"], "familiar_coherence"); + assert_eq!(listed["approvalPath"]["label"], "familiar_review"); + assert_eq!( + listed["approvalPath"]["veto_deadline"], + "2023-11-14T22:18:20Z" + ); + assert_eq!(listed["lifecycle"], "veto_window_open"); + assert_eq!(listed["earliestClose"], "2023-11-14T22:14:20Z"); + assert_eq!(listed["affectedRegions"][0], "tool_defaults"); + assert!(listed.get("reviewKind").is_none()); + Ok(()) + } + + #[test] + fn threads_proposals_does_not_fallback_malformed_phase5_to_legacy() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (pending_path, _) = stage_pending_protected_edit(home)?; + let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&pending_path)?)?; + value["classification"] = serde_json::json!({}); + std::fs::write(&pending_path, serde_json::to_vec_pretty(&value)?)?; + + let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + + assert_eq!(response.status, 200, "got {}", response.body); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!( + body["proposals"][0]["degraded"]["reason"], + "proposal-unparseable" + ); + assert!(body["proposals"][0].get("proposalId").is_none()); + Ok(()) + } + #[test] fn put_familiar_icon_updates_existing_value() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/crates/coven-cli/src/main.rs b/crates/coven-cli/src/main.rs index e14e12c3..bb50546a 100644 --- a/crates/coven-cli/src/main.rs +++ b/crates/coven-cli/src/main.rs @@ -35,6 +35,7 @@ mod pc; mod privacy; mod project; mod prompt_refs; +mod proposal_scheduler; mod pty_runner; mod repos_config; mod settings; diff --git a/crates/coven-cli/src/proposal_scheduler.rs b/crates/coven-cli/src/proposal_scheduler.rs new file mode 100644 index 00000000..07848855 --- /dev/null +++ b/crates/coven-cli/src/proposal_scheduler.rs @@ -0,0 +1,708 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{bail, Context, Result}; +use coven_threads_core as threads; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use time::OffsetDateTime; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ProposalEnvelopeSchema { + Phase5V1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub(crate) enum ProposalLifecycle { + AwaitingHumanApproval, + VetoWindowOpen, + ReadyForReplay, + Blocked { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ScheduledProposal { + schema: ProposalEnvelopeSchema, + pending: threads::PendingProposal, + classification: threads::ProposalClassification, + materialized_diff: threads::MaterializedDiff, + region_evidence: Vec, + lifecycle: ProposalLifecycle, + staged_at: OffsetDateTime, + veto_deadline: Option, + earliest_close: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ScheduledProposalWire { + schema: ProposalEnvelopeSchema, + pending: threads::PendingProposal, + classification: threads::ProposalClassification, + materialized_diff: threads::MaterializedDiff, + region_evidence: Vec, + lifecycle: ProposalLifecycle, + staged_at: OffsetDateTime, + veto_deadline: Option, + earliest_close: Option, +} + +impl ScheduledProposal { + pub(crate) fn try_new( + pending: threads::PendingProposal, + classification: threads::ProposalClassification, + materialized_diff: threads::MaterializedDiff, + ) -> Result { + if pending.id != classification.proposal_id { + bail!("pending proposal id does not match classification"); + } + if pending.familiar_id != classification.familiar_id { + bail!("pending familiar id does not match classification"); + } + if pending.channel != classification.channel { + bail!("pending channel does not match classification"); + } + if classification.path_tier_floor == 0 { + bail!("protected surfaces are not schedulable through ApprovalPath"); + } + if classification.path_tier_floor > 3 { + bail!("classification path tier floor is outside the Ward tier range"); + } + if classification.path_tier_floor == 1 + && matches!( + &classification.approval_path, + threads::ApprovalPath::AutoRegression { .. } + ) + { + bail!("reviewed tier requires familiar review or a stronger approval path"); + } + + let region_evidence = + threads::SurfaceRegionRegistry::default_registry().classify_all(&materialized_diff); + validate_materialized_edits(&pending, &classification, &materialized_diff)?; + validate_region_evidence(&classification, &materialized_diff, ®ion_evidence)?; + + let replay_hash = threads::evidence_replay_hash(&materialized_diff, ®ion_evidence); + if replay_hash != classification.evidence_replay_hash { + bail!("classification replay hash does not commit the materialized evidence"); + } + + let staged_at = pending.staged_at; + let (lifecycle, veto_deadline, earliest_close) = + match classification.approval_path.veto_window() { + Some(window) => ( + ProposalLifecycle::VetoWindowOpen, + Some( + window + .deadline(staged_at) + .map_err(anyhow::Error::msg) + .context("deriving veto deadline")?, + ), + Some( + window + .earliest_close(staged_at) + .map_err(anyhow::Error::msg) + .context("deriving minimum visibility deadline")?, + ), + ), + None => match classification.approval_path { + threads::ApprovalPath::AutoRegression { .. } => { + (ProposalLifecycle::ReadyForReplay, None, None) + } + threads::ApprovalPath::HumanApproval + | threads::ApprovalPath::HumanApprovalWithRationale => { + (ProposalLifecycle::AwaitingHumanApproval, None, None) + } + threads::ApprovalPath::FamiliarCoherence { .. } => { + unreachable!("familiar coherence always has a veto window") + } + }, + }; + + Ok(Self { + schema: ProposalEnvelopeSchema::Phase5V1, + pending, + classification, + materialized_diff, + region_evidence, + lifecycle, + staged_at, + veto_deadline, + earliest_close, + }) + } + + pub(crate) fn lifecycle(&self) -> &ProposalLifecycle { + &self.lifecycle + } + + pub(crate) fn pending(&self) -> &threads::PendingProposal { + &self.pending + } + + pub(crate) fn classification(&self) -> &threads::ProposalClassification { + &self.classification + } + + pub(crate) fn earliest_close(&self) -> Option { + self.earliest_close + } +} + +impl TryFrom for ScheduledProposal { + type Error = anyhow::Error; + + fn try_from(wire: ScheduledProposalWire) -> Result { + let expected = Self::try_new(wire.pending, wire.classification, wire.materialized_diff)?; + if wire.region_evidence != expected.region_evidence { + bail!("persisted region evidence does not match daemon predicate replay"); + } + if wire.schema != expected.schema + || wire.staged_at != expected.staged_at + || wire.lifecycle != expected.lifecycle + || wire.veto_deadline != expected.veto_deadline + || wire.earliest_close != expected.earliest_close + { + bail!("scheduled proposal carries inconsistent derived lifecycle state"); + } + Ok(expected) + } +} + +impl<'de> Deserialize<'de> for ScheduledProposal { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + validate_scheduled_json_shape(&value).map_err(serde::de::Error::custom)?; + serde_json::from_value::(value) + .map_err(serde::de::Error::custom)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +fn validate_materialized_edits( + pending: &threads::PendingProposal, + classification: &threads::ProposalClassification, + materialized_diff: &threads::MaterializedDiff, +) -> Result<()> { + let mut staged_after = BTreeMap::new(); + for edit in &pending.edits { + let surface = edit.surface.as_str().to_string(); + let after = edit + .contents + .to_bytes() + .map_err(anyhow::Error::msg) + .with_context(|| format!("decoding staged surface {surface}"))?; + if staged_after.insert(surface.clone(), after).is_some() { + bail!("pending proposal contains duplicate surface {surface}"); + } + } + + let mut diff_after = BTreeMap::new(); + for surface in materialized_diff.surfaces() { + let name = surface.surface.as_str().to_string(); + let Some(after) = &surface.after else { + bail!("pending proposal cannot schedule deletion of surface {name}"); + }; + if diff_after.insert(name.clone(), after.clone()).is_some() { + bail!("materialized diff contains duplicate surface {name}"); + } + } + if staged_after != diff_after { + bail!("pending edits do not match the complete materialized diff"); + } + + let affected: BTreeSet = classification + .affected_surfaces + .iter() + .map(|surface| surface.as_str().to_string()) + .collect(); + if affected.len() != classification.affected_surfaces.len() { + bail!("classification contains duplicate affected surfaces"); + } + if affected != diff_after.keys().cloned().collect() { + bail!("classification affected surfaces do not match the materialized diff"); + } + Ok(()) +} + +fn validate_region_evidence( + classification: &threads::ProposalClassification, + materialized_diff: &threads::MaterializedDiff, + region_evidence: &[threads::RegionEvidence], +) -> Result<()> { + let classified_regions: BTreeSet = classification + .affected_regions + .iter() + .map(|region| region.as_str().to_string()) + .collect(); + if classified_regions.len() != classification.affected_regions.len() { + bail!("classification contains duplicate affected regions"); + } + + let mut evidenced_regions = BTreeSet::new(); + let diff_surfaces: BTreeSet<&str> = materialized_diff + .surfaces() + .iter() + .map(|surface| surface.surface.as_str()) + .collect(); + for evidence in region_evidence { + if !evidenced_regions.insert(evidence.region_id.as_str().to_string()) { + bail!( + "region evidence contains duplicate region {}", + evidence.region_id + ); + } + if evidence + .affected_surfaces + .iter() + .any(|surface| !diff_surfaces.contains(surface.as_str())) + { + bail!( + "region {} references a surface outside the materialized diff", + evidence.region_id + ); + } + } + if classified_regions != evidenced_regions { + bail!("classification affected regions do not match region evidence"); + } + let region_floor = threads::SurfaceRegionRegistry::path_tier_floor(region_evidence); + if classification.path_tier_floor > region_floor { + bail!("classification path tier floor is weaker than region evidence"); + } + Ok(()) +} + +fn validate_scheduled_json_shape(value: &Value) -> Result<()> { + let root = strict_object( + value, + "scheduled proposal", + &[ + "schema", + "pending", + "classification", + "materialized_diff", + "region_evidence", + "lifecycle", + "staged_at", + "veto_deadline", + "earliest_close", + ], + )?; + let pending = required_object(root, "pending", "pending proposal")?; + reject_unknown_keys( + pending, + "pending proposal", + &[ + "id", + "familiar_id", + "writer", + "channel", + "thread_id", + "fray", + "edits", + "staged_at", + ], + )?; + validate_fray( + pending + .get("fray") + .ok_or_else(|| anyhow::anyhow!("pending proposal is missing fray"))?, + )?; + let edits = pending + .get("edits") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("pending proposal edits must be an array"))?; + for edit in edits { + let edit = strict_object(edit, "pending edit", &["surface", "contents"])?; + strict_object( + edit.get("contents") + .ok_or_else(|| anyhow::anyhow!("pending edit is missing contents"))?, + "staged contents", + &["encoding", "data"], + )?; + } + + let classification = required_object(root, "classification", "proposal classification")?; + reject_unknown_keys( + classification, + "proposal classification", + &[ + "proposal_id", + "familiar_id", + "channel", + "affected_surfaces", + "affected_regions", + "path_tier_floor", + "approval_path", + "evidence_replay_hash", + "classified_at", + ], + )?; + let approval_path = required_object(classification, "approval_path", "approval path")?; + let approval_kind = approval_path + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("approval path is missing kind"))?; + match approval_kind { + "auto_regression" => { + reject_unknown_keys(approval_path, "approval path", &["kind", "veto"])?; + } + "familiar_coherence" => { + reject_unknown_keys(approval_path, "approval path", &["kind", "veto"])?; + if approval_path.get("veto").is_none_or(Value::is_null) { + bail!("familiar coherence approval path requires veto"); + } + } + "human_approval" | "human_approval_with_rationale" => { + reject_unknown_keys(approval_path, "approval path", &["kind"])?; + } + _ => bail!("approval path contains unknown kind {approval_kind}"), + } + if let Some(veto) = approval_path.get("veto").filter(|value| !value.is_null()) { + let veto = strict_object(veto, "veto window", &["duration", "min_visible"])?; + for field in ["duration", "min_visible"] { + strict_object( + veto.get(field) + .ok_or_else(|| anyhow::anyhow!("veto window is missing {field}"))?, + field, + &["secs", "nanos"], + )?; + } + } + + let diff = required_object(root, "materialized_diff", "materialized diff")?; + reject_unknown_keys(diff, "materialized diff", &["surfaces"])?; + let surfaces = diff + .get("surfaces") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("materialized diff surfaces must be an array"))?; + for surface in surfaces { + strict_object( + surface, + "materialized surface", + &["surface", "before", "after"], + )?; + } + + let evidence = root + .get("region_evidence") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("region evidence must be an array"))?; + for item in evidence { + strict_object( + item, + "region evidence", + &[ + "region_id", + "affected_surfaces", + "min_path_tier", + "replay_bytes", + "rationale", + ], + )?; + } + let lifecycle = strict_object( + root.get("lifecycle") + .ok_or_else(|| anyhow::anyhow!("scheduled proposal is missing lifecycle"))?, + "proposal lifecycle", + &["state", "reason"], + )?; + let lifecycle_state = lifecycle + .get("state") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("proposal lifecycle is missing state"))?; + match lifecycle_state { + "awaiting_human_approval" | "veto_window_open" | "ready_for_replay" => { + reject_unknown_keys(lifecycle, "proposal lifecycle", &["state"])?; + } + "blocked" => { + reject_unknown_keys(lifecycle, "proposal lifecycle", &["state", "reason"])?; + if lifecycle + .get("reason") + .and_then(Value::as_str) + .is_none_or(|reason| reason.trim().is_empty()) + { + bail!("blocked proposal lifecycle requires a nonblank reason"); + } + } + _ => bail!("proposal lifecycle contains unknown state {lifecycle_state}"), + } + Ok(()) +} + +fn validate_fray(value: &Value) -> Result<()> { + let fray = value + .as_object() + .ok_or_else(|| anyhow::anyhow!("pending fray must be an object"))?; + if fray.len() != 1 { + bail!("pending fray must contain exactly one variant"); + } + let (variant, payload) = fray.iter().next().expect("length checked"); + let allowed = match variant.as_str() { + "NotCovered" => &["channel"][..], + "Frayed" => &["strand", "channel", "reason"][..], + "Snapped" => &["channel", "reason"][..], + _ => bail!("pending fray contains unknown variant {variant}"), + }; + strict_object(payload, "pending fray payload", allowed)?; + Ok(()) +} + +fn required_object<'a>( + parent: &'a Map, + key: &str, + label: &str, +) -> Result<&'a Map> { + parent + .get(key) + .and_then(Value::as_object) + .ok_or_else(|| anyhow::anyhow!("{label} must be an object")) +} + +fn strict_object<'a>( + value: &'a Value, + label: &str, + allowed: &[&str], +) -> Result<&'a Map> { + let object = value + .as_object() + .ok_or_else(|| anyhow::anyhow!("{label} must be an object"))?; + reject_unknown_keys(object, label, allowed)?; + Ok(object) +} + +fn reject_unknown_keys(object: &Map, label: &str, allowed: &[&str]) -> Result<()> { + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + bail!("{label} contains unknown field {key}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use coven_threads_core as threads; + + use super::*; + + fn fixture( + approval_path: threads::ApprovalPath, + ) -> ( + threads::PendingProposal, + threads::ProposalClassification, + threads::MaterializedDiff, + Vec, + ) { + fixture_for_surface(approval_path, "TOOLS.md", 1) + } + + fn fixture_for_surface( + approval_path: threads::ApprovalPath, + surface: &str, + path_tier_floor: u8, + ) -> ( + threads::PendingProposal, + threads::ProposalClassification, + threads::MaterializedDiff, + Vec, + ) { + let proposal_id = threads::ProposalId::new(); + let familiar_id = threads::FamiliarId::new(); + let surface = threads::SurfaceId::new(surface); + let staged_at = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let pending = threads::PendingProposal { + id: proposal_id, + familiar_id, + writer: threads::WriterId::new("principal:fpr-val"), + channel: threads::Channel::Mutation, + thread_id: threads::ThreadId::new(), + fray: threads::FrayOrSnap::Frayed { + strand: None, + channel: threads::Channel::Mutation, + reason: threads::FrayReason::Other("phase-5 test".to_string()), + }, + edits: vec![threads::StagedEdit { + surface: surface.clone(), + contents: threads::StagedContents::from_bytes(b"after"), + }], + staged_at, + }; + let diff = threads::MaterializedDiff::try_new(vec![threads::SurfaceDiff { + surface: surface.clone(), + before: Some(b"before".to_vec()), + after: Some(b"after".to_vec()), + }]) + .unwrap(); + let evidence = threads::SurfaceRegionRegistry::default_registry().classify_all(&diff); + let classification = threads::ProposalClassification { + proposal_id, + familiar_id, + channel: threads::Channel::Mutation, + affected_surfaces: vec![surface], + affected_regions: evidence.iter().map(|item| item.region_id.clone()).collect(), + path_tier_floor, + approval_path, + evidence_replay_hash: threads::evidence_replay_hash(&diff, &evidence), + classified_at: staged_at, + }; + (pending, classification, diff, evidence) + } + + #[test] + fn human_proposal_roundtrips_as_awaiting_approval() { + let (pending, classification, diff, _) = fixture(threads::ApprovalPath::HumanApproval); + + let scheduled = ScheduledProposal::try_new(pending, classification, diff).unwrap(); + let encoded = serde_json::to_string(&scheduled).unwrap(); + let decoded: ScheduledProposal = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(decoded, scheduled); + assert_eq!( + decoded.lifecycle(), + &ProposalLifecycle::AwaitingHumanApproval + ); + assert_eq!(decoded.veto_deadline, None); + assert_eq!(decoded.earliest_close(), None); + } + + #[test] + fn veto_window_derives_deadline_and_minimum_visibility() { + let window = threads::VetoWindow::new(Duration::from_secs(300), Duration::from_secs(60)); + let (pending, classification, diff, _) = + fixture(threads::ApprovalPath::FamiliarCoherence { veto: window }); + let staged_at = pending.staged_at; + + let scheduled = ScheduledProposal::try_new(pending, classification, diff).unwrap(); + + assert_eq!(scheduled.lifecycle(), &ProposalLifecycle::VetoWindowOpen); + assert_eq!( + scheduled.veto_deadline, + Some(staged_at + time::Duration::seconds(300)) + ); + assert_eq!( + scheduled.earliest_close(), + Some(staged_at + time::Duration::seconds(60)) + ); + } + + #[test] + fn protected_path_floor_is_not_schedulable() { + let (pending, mut classification, diff, _) = fixture(threads::ApprovalPath::HumanApproval); + classification.path_tier_floor = 0; + + let error = ScheduledProposal::try_new(pending, classification, diff) + .expect_err("protected proposals must stay outside ApprovalPath"); + + assert!(error.to_string().contains("protected")); + } + + #[test] + fn replay_hash_mismatch_fails_closed() { + let (pending, mut classification, diff, _) = fixture(threads::ApprovalPath::HumanApproval); + classification.evidence_replay_hash = [0xff; 32]; + + let error = ScheduledProposal::try_new(pending, classification, diff) + .expect_err("uncommitted evidence must be rejected"); + + assert!(error.to_string().contains("replay hash")); + } + + #[test] + fn region_tier_zero_cannot_be_hidden_by_classification() { + let (pending, classification, diff, _) = + fixture_for_surface(threads::ApprovalPath::HumanApproval, "AGENTS.md", 1); + + let error = ScheduledProposal::try_new(pending, classification, diff) + .expect_err("region protection must constrain the classification floor"); + + assert!(error.to_string().contains("weaker than region evidence")); + } + + #[test] + fn deserialization_replays_region_predicates_instead_of_trusting_evidence() { + let (pending, classification, diff, mut forged_evidence) = + fixture(threads::ApprovalPath::HumanApproval); + let scheduled = ScheduledProposal::try_new(pending, classification, diff.clone()).unwrap(); + forged_evidence[0].min_path_tier = 0; + let mut value = serde_json::to_value(scheduled).unwrap(); + value["region_evidence"] = serde_json::to_value(&forged_evidence).unwrap(); + value["classification"]["evidence_replay_hash"] = + serde_json::to_value(threads::evidence_replay_hash(&diff, &forged_evidence)).unwrap(); + + let error = serde_json::from_value::(value) + .expect_err("persisted evidence must match daemon predicate replay"); + + assert!( + error.to_string().contains("replay hash") + || error.to_string().contains("daemon predicate replay") + ); + } + + #[test] + fn reviewed_tier_cannot_use_auto_approval() { + let (pending, classification, diff, _) = + fixture(threads::ApprovalPath::AutoRegression { veto: None }); + + let error = ScheduledProposal::try_new(pending, classification, diff) + .expect_err("tier one requires familiar review or stronger"); + + assert!(error.to_string().contains("reviewed tier")); + } + + #[test] + fn deserialization_rejects_unknown_fields() { + let (pending, classification, diff, _) = fixture(threads::ApprovalPath::HumanApproval); + let scheduled = ScheduledProposal::try_new(pending, classification, diff).unwrap(); + let mut value = serde_json::to_value(scheduled).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("clientPolicy".to_string(), serde_json::json!("auto")); + + let error = serde_json::from_value::(value) + .expect_err("clients cannot add policy-bearing fields"); + + assert!(error.to_string().contains("unknown field")); + } + + #[test] + fn deserialization_rejects_unknown_nested_policy_fields() { + let (pending, classification, diff, _) = fixture(threads::ApprovalPath::HumanApproval); + let scheduled = ScheduledProposal::try_new(pending, classification, diff).unwrap(); + let mut value = serde_json::to_value(scheduled).unwrap(); + value["classification"] + .as_object_mut() + .unwrap() + .insert("clientPolicy".to_string(), serde_json::json!("auto")); + + let error = serde_json::from_value::(value) + .expect_err("nested policy fields must be rejected"); + + assert!(error.to_string().contains("unknown field clientPolicy")); + } + + #[test] + fn deserialization_rejects_fields_from_the_wrong_policy_variant() { + let (pending, classification, diff, _) = fixture(threads::ApprovalPath::HumanApproval); + let scheduled = ScheduledProposal::try_new(pending, classification, diff).unwrap(); + let mut approval_value = serde_json::to_value(&scheduled).unwrap(); + approval_value["classification"]["approval_path"]["veto"] = + serde_json::json!({"duration":{"secs":1,"nanos":0},"min_visible":{"secs":1,"nanos":0}}); + let approval_error = serde_json::from_value::(approval_value) + .expect_err("human approval cannot carry veto settings"); + assert!(approval_error.to_string().contains("unknown field veto")); + + let mut lifecycle_value = serde_json::to_value(&scheduled).unwrap(); + lifecycle_value["lifecycle"]["reason"] = serde_json::json!("forged"); + let lifecycle_error = serde_json::from_value::(lifecycle_value) + .expect_err("non-blocked lifecycle cannot carry a blocked reason"); + assert!(lifecycle_error.to_string().contains("unknown field reason")); + } +} From 5ed9c56302e19edad1ed21c1cfc28fee4c7d783a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:35:38 -0500 Subject: [PATCH 3/7] feat(ward): recover and schedule Phase 5 decisions Add daemon-owned delayed proposal processing, durable decision claims, fail-closed evidence replay, transactional audit/baseline finalization, and concurrency-safe approved writes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/coven-cli/src/api.rs | 8559 +++++++++++++------- crates/coven-cli/src/daemon.rs | 25 + crates/coven-cli/src/proposal_scheduler.rs | 8 + crates/coven-cli/src/threads_gate.rs | 49 +- crates/coven-cli/src/ward.rs | 568 +- 5 files changed, 6210 insertions(+), 2999 deletions(-) diff --git a/crates/coven-cli/src/api.rs b/crates/coven-cli/src/api.rs index 8ba699ba..a47e25be 100644 --- a/crates/coven-cli/src/api.rs +++ b/crates/coven-cli/src/api.rs @@ -1,9 +1,10 @@ use std::{ borrow::Cow, - collections::HashSet, + collections::{BTreeMap, HashSet}, fs, io::Write, path::{Path, PathBuf}, + sync::{Mutex, OnceLock}, }; use anyhow::{Context, Result}; @@ -29,6 +30,90 @@ pub const COVEN_API_NAMED_VERSION: &str = "coven.daemon.v1"; pub const COVEN_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const SUPPORTED_API_VERSIONS: [&str; 1] = [COVEN_API_VERSION]; +fn proposal_decision_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProposalDecisionFailpoint { + ClaimBeforeValidation, + ApplyBeforeAudit, + AuditBeforeCleanup, +} + +#[cfg(test)] +fn proposal_decision_failpoint( +) -> &'static Mutex> { + static FAILPOINTS: OnceLock< + Mutex>, + > = OnceLock::new(); + FAILPOINTS.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +#[cfg(test)] +fn set_proposal_decision_failpoint(failpoint: Option<(ProposalDecisionFailpoint, String)>) { + let mut failpoints = proposal_decision_failpoint() + .lock() + .expect("proposal decision failpoint lock poisoned"); + match failpoint { + Some((checkpoint, proposal_id)) => { + failpoints.insert(proposal_id, checkpoint); + } + None => failpoints.clear(), + } +} + +#[cfg(test)] +fn forced_recovery_ward_refusals() -> &'static Mutex> { + static REFUSALS: OnceLock>> = OnceLock::new(); + REFUSALS.get_or_init(|| Mutex::new(HashSet::new())) +} + +#[cfg(test)] +fn force_recovery_ward_refusal(proposal_id: impl Into) { + forced_recovery_ward_refusals() + .lock() + .expect("recovery refusal test hook lock poisoned") + .insert(proposal_id.into()); +} + +fn recovery_authorization( + proposal_id: &str, + authorization: &ward::Authorization, +) -> ward::Authorization { + #[cfg(test)] + if forced_recovery_ward_refusals() + .lock() + .expect("recovery refusal test hook lock poisoned") + .remove(proposal_id) + { + return ward::Authorization::unsigned(); + } + #[cfg(not(test))] + let _ = proposal_id; + authorization.clone() +} + +fn maybe_fail_proposal_decision( + checkpoint: ProposalDecisionFailpoint, + proposal_id: &str, +) -> Result<()> { + #[cfg(test)] + { + let mut failpoints = proposal_decision_failpoint() + .lock() + .expect("proposal decision failpoint lock poisoned"); + if failpoints.get(proposal_id) == Some(&checkpoint) { + failpoints.remove(proposal_id); + anyhow::bail!("injected proposal decision interruption at {checkpoint:?}"); + } + } + #[cfg(not(test))] + let _ = (checkpoint, proposal_id); + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HealthCapabilities { @@ -2855,21 +2940,15 @@ fn threads_proposals_response(coven_home: &Path, id: Option<&str>) -> Result( - raw_value.clone(), + proposal_value.clone(), ) { Ok(scheduled) => Some(scheduled), Err(_) => { @@ -2883,7 +2962,9 @@ fn threads_proposals_response(coven_home: &Path, id: Option<&str>) -> Result(raw_value.clone()) { + match serde_json::from_value::( + proposal_value.clone(), + ) { Ok(proposal) => Some(proposal), Err(_) => { proposals.push(json!({ @@ -3053,12 +3134,158 @@ fn sanitize_ward_config_error(error: &anyhow::Error, workspace: &Path) -> String sanitized.split_whitespace().collect::>().join(" ") } -fn decide_threads_proposal( +fn is_phase5_proposal_shape(value: &Value) -> bool { + [ + "schema", + "pending", + "classification", + "materialized_diff", + "region_evidence", + "lifecycle", + "veto_deadline", + "earliest_close", + ] + .iter() + .any(|field| value.get(field).is_some()) +} + +struct ProposalDecisionSemantics { + rejection_event: coven_threads_core::AuditEventType, + rejection_decision: &'static str, + approval_path_label: String, + window_close: Option, +} + +fn proposal_decision_semantics( + scheduled: Option<&crate::proposal_scheduler::ScheduledProposal>, + decision: &str, + note: Option<&str>, + now: time::OffsetDateTime, +) -> std::result::Result { + let Some(scheduled) = scheduled else { + return Ok(ProposalDecisionSemantics { + rejection_event: coven_threads_core::AuditEventType::ProposalRejected, + rejection_decision: "rejected", + approval_path_label: "human_review".to_string(), + window_close: None, + }); + }; + let path = &scheduled.classification().approval_path; + let approval_path_label = path.display_label().to_string(); + match (decision, path) { + ("approve", coven_threads_core::ApprovalPath::HumanApproval) => {} + ("approve", coven_threads_core::ApprovalPath::HumanApprovalWithRationale) => { + if note.is_none_or(|note| note.trim().is_empty()) { + return Err("proposal-rationale-required"); + } + } + ( + "approve", + coven_threads_core::ApprovalPath::AutoRegression { veto: Some(_) } + | coven_threads_core::ApprovalPath::FamiliarCoherence { .. }, + ) => { + if scheduled.earliest_close().is_some_and(|close| now < close) { + return Err("proposal-minimum-visibility-open"); + } + if scheduled + .veto_deadline() + .is_some_and(|deadline| now < deadline) + { + return Err("proposal-veto-window-open"); + } + } + ("approve", coven_threads_core::ApprovalPath::AutoRegression { veto: None }) => {} + ( + "reject", + coven_threads_core::ApprovalPath::AutoRegression { veto: Some(_) } + | coven_threads_core::ApprovalPath::FamiliarCoherence { .. }, + ) => { + if scheduled + .veto_deadline() + .is_some_and(|deadline| now >= deadline) + { + return Err("proposal-veto-window-closed"); + } + return Ok(ProposalDecisionSemantics { + rejection_event: coven_threads_core::AuditEventType::ProposalVetoed, + rejection_decision: "vetoed", + approval_path_label, + window_close: Some(coven_threads_core::ProposalWindowCloseAuditDetail { + reason: coven_threads_core::WindowCloseReason::Vetoed, + replay_hash_matched: None, + rationale: note.map(str::to_string), + }), + }); + } + ( + "reject", + coven_threads_core::ApprovalPath::HumanApproval + | coven_threads_core::ApprovalPath::HumanApprovalWithRationale, + ) => { + return Ok(ProposalDecisionSemantics { + rejection_event: coven_threads_core::AuditEventType::ProposalRejected, + rejection_decision: "rejected", + approval_path_label, + window_close: None, + }); + } + ("reject", coven_threads_core::ApprovalPath::AutoRegression { veto: None }) => { + return Err("proposal-not-human-decidable"); + } + _ => return Err("proposal-decision-invalid"), + } + + let window_close = + scheduled + .veto_deadline() + .map(|_| coven_threads_core::ProposalWindowCloseAuditDetail { + reason: coven_threads_core::WindowCloseReason::Applied, + replay_hash_matched: Some(true), + rationale: note.map(str::to_string), + }); + Ok(ProposalDecisionSemantics { + rejection_event: coven_threads_core::AuditEventType::ProposalRejected, + rejection_decision: "rejected", + approval_path_label, + window_close, + }) +} + +fn revalidate_scheduled_materialized_before( + workspace: &Path, + scheduled: &crate::proposal_scheduler::ScheduledProposal, +) -> std::result::Result<(), &'static str> { + for surface in scheduled.materialized_diff().surfaces() { + let Some(expected_before) = surface.before.as_deref() else { + return Err("proposal-atomic-create-unsupported"); + }; + let current = crate::threads_gate::read_surface(workspace, surface.surface.as_str()) + .map_err(|_| "proposal-evidence-replay-failed")?; + if current != expected_before { + return Err("proposal-evidence-diverged"); + } + } + Ok(()) +} + +fn ward_tier_number(tier: ward::Tier) -> u8 { + match tier { + ward::Tier::Protected => 0, + ward::Tier::Reviewed => 1, + ward::Tier::Logged => 2, + ward::Tier::Free => 3, + } +} + +pub(crate) fn decide_threads_proposal( coven_home: &Path, proposal_id: &str, decision: &str, body: Option<&str>, ) -> Result { + let _decision_guard = proposal_decision_lock() + .lock() + .map_err(|_| anyhow::anyhow!("proposal decision lock poisoned"))?; let proposal_uuid = match Uuid::parse_str(proposal_id) { Ok(uuid) => uuid, Err(_) => { @@ -3078,22 +3305,172 @@ fn decide_threads_proposal( }, Err(_) => return json_response(400, &json!({ "blocked": true, "why": "invalid-json" })), }; - let Some(path) = find_pending_proposal(coven_home, proposal_uuid)? else { + let conn = store::open_store(&store_path(coven_home))?; + if let Some(terminal) = proposal_terminal_event(&conn, proposal_id)? { + let matches_request = matches!( + (decision, terminal.event_type.as_str()), + ("approve", "proposal_approved") + | ("reject", "proposal_rejected") + | ("reject", "proposal_vetoed") + ); + cleanup_terminal_proposal_artifacts(coven_home, proposal_uuid)?; + if !matches_request { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-already-decided", + "eventType": terminal.event_type, + }), + ); + } return json_response( - 404, - &json!({ "blocked": true, "why": "proposal-not-found" }), + 200, + &json!({ + "ok": true, + "decision": terminal_decision_label(&terminal.event_type), + "proposalId": proposal_id, + "filesTouched": terminal.files_touched, + "idempotent": true, + }), ); + } + let mut claim = + match PendingDecisionClaim::acquire(coven_home, proposal_uuid, decision, note.as_deref()) { + Ok(Some(claim)) => claim, + Ok(None) => { + return json_response( + 404, + &json!({ "blocked": true, "why": "proposal-not-found" }), + ) + } + Err(error) + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) => + { + return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })) + } + Err(error) if error.to_string().contains("already claimed") => { + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-decision-in-progress" }), + ) + } + Err(error) => return Err(error), + }; + if claim.recovery { + claim.preserve(); + } + let raw = fs::read_to_string(&claim.path) + .with_context(|| format!("reading proposal decision claim {}", claim.path.display()))?; + let mut raw_value: Value = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(_) => { + return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })) + } }; - let pending: coven_threads_core::PendingProposal = match fs::read_to_string(&path) - .ok() - .and_then(|raw| serde_json::from_str(&raw).ok()) + let durable_request = proposal_decision_request(&raw_value)?; + maybe_fail_proposal_decision( + ProposalDecisionFailpoint::ClaimBeforeValidation, + proposal_id, + )?; + if durable_request + .as_ref() + .is_some_and(|request| request.decision != decision) { - Some(pending) => pending, - None => return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })), + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-decision-conflict" }), + ); + } + if let Some(request) = durable_request.as_ref() { + if note.is_some() && note != request.rationale { + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-recovery-request-conflict" }), + ); + } + } + let note = durable_request + .as_ref() + .map(|request| request.rationale.clone()) + .unwrap_or(note); + let applying_state = proposal_applying_state(&raw_value)?; + if applying_state.is_some() { + claim.preserve(); + } + let mut authority_value = raw_value.clone(); + if let Some(object) = authority_value.as_object_mut() { + object.remove("decisionState"); + object.remove("decisionRequest"); + } + let phase5_shape = is_phase5_proposal_shape(&authority_value); + let scheduled = if phase5_shape { + match serde_json::from_value::( + authority_value.clone(), + ) { + Ok(scheduled) => Some(scheduled), + Err(_) => { + return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })) + } + } + } else { + None + }; + let pending: coven_threads_core::PendingProposal = match &scheduled { + Some(scheduled) => scheduled.pending().clone(), + None => match serde_json::from_value(authority_value.clone()) { + Ok(pending) => pending, + Err(_) => { + return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })) + } + }, }; if pending.id.0 != proposal_uuid { return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })); } + let note = if let Some(applying) = applying_state.as_ref() { + if applying.decision != decision { + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-decision-conflict" }), + ); + } + if note != applying.rationale { + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-recovery-request-conflict" }), + ); + } + applying.rationale.clone() + } else { + note + }; + let decision_semantics = match proposal_decision_semantics( + scheduled.as_ref(), + decision, + note.as_deref(), + durable_request + .as_ref() + .map(|request| request.claimed_at) + .unwrap_or_else(time::OffsetDateTime::now_utc), + ) { + Ok(semantics) => semantics, + Err(reason) => { + if applying_state.is_none() { + claim.restore_pending(&mut raw_value)?; + } + return json_response( + 409, + &json!({ + "blocked": true, + "why": reason, + "proposalId": proposal_id, + }), + ); + } + }; let Some(familiar_id) = human_familiar_id_for_weave(coven_home, pending.familiar_id)? else { return json_response( 409, @@ -3120,7 +3497,107 @@ fn decide_threads_proposal( targets: targets.clone(), authorization: authorization.clone(), }); - let conn = store::open_store(&store_path(coven_home))?; + if let Some(terminal) = proposal_terminal_event(&conn, proposal_id)? { + let matches_request = matches!( + (decision, terminal.event_type.as_str()), + ("approve", "proposal_approved") + | ("reject", "proposal_rejected") + | ("reject", "proposal_vetoed") + ); + claim.consume()?; + if !matches_request { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-already-decided", + "eventType": terminal.event_type, + }), + ); + } + return json_response( + 200, + &json!({ + "ok": true, + "decision": terminal_decision_label(&terminal.event_type), + "proposalId": proposal_id, + "filesTouched": terminal.files_touched, + "idempotent": true, + }), + ); + } + if let Some(applying) = applying_state.as_ref() { + let recorded_intent = load_proposal_apply_intent(&conn, proposal_id)?; + if recorded_intent.as_ref() != Some(applying) { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-recovery-intent-unverifiable", + "proposalId": proposal_id, + }), + ); + } + let recovery_commitment = + proposal_recovery_commitment(&conn, &config, &authority_value, &familiar_id, &targets)?; + if applying.recovery_commitment != recovery_commitment { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-recovery-evidence-diverged", + "proposalId": proposal_id, + }), + ); + } + if adjudication.is_blocked() { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-recovery-revalidation-failed", + "proposalId": proposal_id, + }), + ); + } + } + if scheduled.is_some() && applying_state.is_none() && adjudication.is_blocked() { + let state = crate::threads_gate::build_weave_state_for_writer( + &conn, + &familiar_id, + &workspace, + &config, + &[], + false, + Some(&pending.writer), + )?; + claim.preserve(); + append_proposal_decision_audit( + &conn, + ProposalDecisionAudit { + event_type: coven_threads_core::AuditEventType::ProposalRejected, + proposal_id, + familiar_id: &familiar_id, + weave_hash: state.weave.weave_hash(), + approver: Some(&pending.writer), + files_touched: &targets, + decision: "proposal-live-adjudication-failed", + approval_rationale: note.as_deref(), + approval_path_label: &decision_semantics.approval_path_label, + window_close: None, + channel: pending.channel, + }, + )?; + claim.consume()?; + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-live-adjudication-failed", + "proposalId": proposal_id, + }), + ); + } if adjudication.is_blocked() { let state = crate::threads_gate::build_weave_state( &conn, @@ -3137,7 +3614,9 @@ fn decide_threads_proposal( state.weave.weave_hash(), &pending.writer, &targets, + pending.channel, )?; + claim.restore_pending(&mut raw_value)?; return json_response( 409, &json!({ "blocked": true, "why": "proposal-revalidation-failed" }), @@ -3146,17 +3625,31 @@ fn decide_threads_proposal( let gated_targets: Vec = adjudication .decisions .iter() - .filter(|d| d.tier == ward::Tier::Protected && !d.verdict.is_blocked()) + .filter(|d| { + !d.verdict.is_blocked() && (scheduled.is_some() || d.tier == ward::Tier::Protected) + }) .map(|d| d.resolved.clone()) .collect(); - let state = crate::threads_gate::build_weave_state( - &conn, - &familiar_id, - &workspace, - &config, - &gated_targets, - false, - )?; + let state = if scheduled.is_some() { + crate::threads_gate::build_weave_state_for_writer( + &conn, + &familiar_id, + &workspace, + &config, + &gated_targets, + false, + Some(&pending.writer), + )? + } else { + crate::threads_gate::build_weave_state( + &conn, + &familiar_id, + &workspace, + &config, + &gated_targets, + false, + )? + }; if decision == "approve" && gated_targets.is_empty() { append_proposal_refusal_audit( &conn, @@ -3165,7 +3658,9 @@ fn decide_threads_proposal( state.weave.weave_hash(), &pending.writer, &targets, + pending.channel, )?; + claim.restore_pending(&mut raw_value)?; return json_response( 409, &json!({ "blocked": true, "why": "proposal-revalidation-failed" }), @@ -3173,25 +3668,30 @@ fn decide_threads_proposal( } if decision == "reject" { + claim.preserve(); append_proposal_decision_audit( &conn, ProposalDecisionAudit { - event_type: coven_threads_core::AuditEventType::ProposalRejected, + event_type: decision_semantics.rejection_event, proposal_id, familiar_id: &familiar_id, weave_hash: state.weave.weave_hash(), approver: Some(&pending.writer), files_touched: &targets, - decision: "rejected", - approval_rationale: None, + decision: decision_semantics.rejection_decision, + approval_rationale: note.as_deref(), + approval_path_label: &decision_semantics.approval_path_label, + window_close: decision_semantics.window_close.as_ref(), + channel: pending.channel, }, )?; - fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; + maybe_fail_proposal_decision(ProposalDecisionFailpoint::AuditBeforeCleanup, proposal_id)?; + claim.consume()?; return json_response( 200, &json!({ "ok": true, - "decision": "rejected", + "decision": decision_semantics.rejection_decision, "proposalId": proposal_id, "filesTouched": targets, "note": note, @@ -3199,35 +3699,177 @@ fn decide_threads_proposal( ); } - for target in &gated_targets { - let request = coven_threads_core::MutationRequest { - surface: coven_threads_core::SurfaceId::new(target.clone()), - writer: pending.writer.clone(), - channel: coven_threads_core::Channel::Mutation, - identity_context: None, - }; - let verdict = coven_threads_core::validate_fail_closed(&state.weave, &request); - crate::threads_gate::append_audit_row( - &conn, - &familiar_id, - &state.familiar_uuid, - state.weave.weave_hash(), - &request, - &verdict, - time::OffsetDateTime::now_utc(), - )?; - if !verdict.permits_write() { - append_proposal_refusal_audit( - &conn, - proposal_id, - &familiar_id, - state.weave.weave_hash(), - &pending.writer, - &targets, - )?; - return json_response( - 409, - &json!({ + if applying_state.is_none() { + if let Some(scheduled) = scheduled.as_ref() { + let live_tier_escalated = adjudication.decisions.iter().any(|decision| { + ward_tier_number(decision.tier) < scheduled.classification().path_tier_floor + }); + let rejection = if live_tier_escalated { + Some("proposal-live-tier-escalated") + } else { + revalidate_scheduled_materialized_before(&workspace, scheduled).err() + }; + if let Some(reason) = rejection { + claim.preserve(); + append_proposal_decision_audit( + &conn, + ProposalDecisionAudit { + event_type: coven_threads_core::AuditEventType::ProposalRejected, + proposal_id, + familiar_id: &familiar_id, + weave_hash: state.weave.weave_hash(), + approver: Some(&pending.writer), + files_touched: &targets, + decision: reason, + approval_rationale: note.as_deref(), + approval_path_label: &decision_semantics.approval_path_label, + window_close: None, + channel: pending.channel, + }, + )?; + claim.consume()?; + return json_response( + 409, + &json!({ + "blocked": true, + "why": reason, + "proposalId": proposal_id, + }), + ); + } + } + } + + if !ward::supports_atomic_approved_writes() { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-atomic-commit-unsupported", + "proposalId": proposal_id, + }), + ); + } + if let Some(applying) = applying_state { + if let Err(error) = verify_recoverable_apply_state(&workspace, &pending, &applying) { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-recovery-surface-diverged", + "proposalId": proposal_id, + "error": error.to_string(), + }), + ); + } + let expected_before = proposal_expected_before(&applying)?; + let recovery_authorization = recovery_authorization(proposal_id, &authorization); + if !ward_config_is_unchanged(&workspace, &config)? { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-recovery-evidence-diverged", + "proposalId": proposal_id, + }), + ); + } + let report = + ward.apply_after_threads_approval(&edits, &recovery_authorization, &expected_before)?; + if report.is_refused() { + let rollback_edits = proposal_rollback_edits(&applying)?; + let expected_after = proposal_expected_after(&edits); + let rollback = ward.apply_after_threads_approval( + &rollback_edits, + &authorization, + &expected_after, + )?; + if rollback.is_refused() { + anyhow::bail!( + "Ward refused both recovery and restoration for proposal {proposal_id}" + ); + } + append_proposal_refusal_audit( + &conn, + proposal_id, + &familiar_id, + &applying.weave_hash, + &pending.writer, + &targets, + pending.channel, + )?; + claim.restore_pending(&mut raw_value)?; + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-recovery-revalidation-failed", + "proposalId": proposal_id, + }), + ); + } + let approved_bytes = approved_bytes_by_resolved(&report, &edits)?; + finalize_approved_proposal( + &conn, + ApprovedProposalFinalization { + proposal_id, + familiar_id: &familiar_id, + workspace: &workspace, + weave_hash: &applying.weave_hash, + approver: &pending.writer, + gated_targets: &gated_targets, + approved_bytes: &approved_bytes, + files_touched: &targets, + rationale: applying.rationale.as_deref(), + approval_path_label: &decision_semantics.approval_path_label, + window_close: decision_semantics.window_close.as_ref(), + channel: pending.channel, + }, + )?; + maybe_fail_proposal_decision(ProposalDecisionFailpoint::AuditBeforeCleanup, proposal_id)?; + claim.consume()?; + return json_response( + 200, + &json!({ + "ok": true, + "decision": "approved", + "proposalId": proposal_id, + "filesTouched": targets, + "recovered": true, + }), + ); + } + + for target in &gated_targets { + let request = coven_threads_core::MutationRequest { + surface: coven_threads_core::SurfaceId::new(target.clone()), + writer: pending.writer.clone(), + channel: pending.channel, + identity_context: None, + }; + let verdict = coven_threads_core::validate_fail_closed(&state.weave, &request); + crate::threads_gate::append_audit_row( + &conn, + &familiar_id, + &state.familiar_uuid, + state.weave.weave_hash(), + &request, + &verdict, + time::OffsetDateTime::now_utc(), + )?; + if !verdict.permits_write() { + append_proposal_refusal_audit( + &conn, + proposal_id, + &familiar_id, + state.weave.weave_hash(), + &pending.writer, + &targets, + pending.channel, + )?; + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-revalidation-failed", "proposalId": proposal_id, @@ -3237,7 +3879,42 @@ fn decide_threads_proposal( } } - let report = ward.apply_after_threads_approval(&edits, &authorization)?; + let applying = ProposalApplyingState { + decision: "approve".to_string(), + recovery_commitment: proposal_recovery_commitment( + &conn, + &config, + &authority_value, + &familiar_id, + &targets, + )?, + weave_hash: state.weave.weave_hash().to_vec(), + before_images: proposal_before_images(&workspace, &targets, scheduled.as_ref())?, + rationale: note.clone(), + }; + append_proposal_apply_intent( + &conn, + proposal_id, + &familiar_id, + &pending.writer, + &targets, + &applying, + pending.channel, + )?; + persist_proposal_applying_state(&claim.path, &mut raw_value, &applying)?; + claim.preserve(); + if !ward_config_is_unchanged(&workspace, &config)? { + return json_response( + 409, + &json!({ + "blocked": true, + "why": "proposal-recovery-evidence-diverged", + "proposalId": proposal_id, + }), + ); + } + let expected_before = proposal_expected_before(&applying)?; + let report = ward.apply_after_threads_approval(&edits, &authorization, &expected_before)?; if report.is_refused() { append_proposal_refusal_audit( &conn, @@ -3246,29 +3923,35 @@ fn decide_threads_proposal( state.weave.weave_hash(), &pending.writer, &targets, + pending.channel, )?; + claim.restore_pending(&mut raw_value)?; return json_response( 409, &json!({ "blocked": true, "why": "proposal-revalidation-failed" }), ); } - for target in &gated_targets { - crate::threads_gate::advance_surface_baseline(&conn, &familiar_id, &workspace, target)?; - } - append_proposal_decision_audit( + let approved_bytes = approved_bytes_by_resolved(&report, &edits)?; + maybe_fail_proposal_decision(ProposalDecisionFailpoint::ApplyBeforeAudit, proposal_id)?; + finalize_approved_proposal( &conn, - ProposalDecisionAudit { - event_type: coven_threads_core::AuditEventType::ProposalApproved, + ApprovedProposalFinalization { proposal_id, familiar_id: &familiar_id, + workspace: &workspace, weave_hash: state.weave.weave_hash(), - approver: Some(&pending.writer), + approver: &pending.writer, + gated_targets: &gated_targets, + approved_bytes: &approved_bytes, files_touched: &targets, - decision: "approved", - approval_rationale: note.as_deref(), + rationale: note.as_deref(), + approval_path_label: &decision_semantics.approval_path_label, + window_close: decision_semantics.window_close.as_ref(), + channel: pending.channel, }, )?; - fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; + maybe_fail_proposal_decision(ProposalDecisionFailpoint::AuditBeforeCleanup, proposal_id)?; + claim.consume()?; json_response( 200, &json!({ @@ -3304,317 +3987,1149 @@ fn find_pending_proposal(coven_home: &Path, proposal_id: Uuid) -> Result Result> { - for familiar in crate::cockpit_sources::read_familiars(coven_home)? { - if crate::threads_gate::familiar_weave_id(&familiar.id) == familiar_uuid { - return Ok(Some(familiar.id)); +pub(crate) fn process_due_threads_proposals(coven_home: &Path) -> Result { + let pending_dir = coven_home.join("pending"); + let entries = match fs::read_dir(&pending_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(error) + .with_context(|| format!("reading proposal scheduler {}", pending_dir.display())) + } + }; + let mut claims = Vec::new(); + let mut scheduled = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name.ends_with(".deciding") { + claims.push(path); + } else if name.ends_with(".json") { + scheduled.push(path); } } - Ok(None) -} - -fn authorization_from_writer(writer: &coven_threads_core::WriterId) -> ward::Authorization { - writer - .as_str() - .strip_prefix("principal:") - .map(|fp| ward::Authorization::signed_by(fp.to_string())) - .unwrap_or_else(ward::Authorization::unsigned) -} + claims.sort(); + scheduled.sort(); -fn staged_edits_to_ward_edits( - pending: &coven_threads_core::PendingProposal, -) -> Result> { - pending - .edits - .iter() - .map(|edit| { - let bytes = edit - .contents - .to_bytes() - .map_err(|err| anyhow::anyhow!("decoding staged contents: {err}"))?; - let contents = String::from_utf8(bytes) - .context("staged proposal contents are not utf8; Ward FileEdit is text-only")?; - Ok(ward::FileEdit::new(edit.surface.as_str(), contents)) - }) - .collect() -} + let mut completed = 0; + for claim_path in claims { + match recover_proposal_claim(coven_home, &claim_path) { + Ok(true) => completed += 1, + Ok(false) => {} + Err(error) => crate::daemon::append_daemon_recovery_log( + coven_home, + &format!( + "threads scheduler: claim recovery failed for {}: {error:#}", + claim_path.display() + ), + ), + } + } -fn append_proposal_refusal_audit( - conn: &rusqlite::Connection, - proposal_id: &str, - familiar_id: &str, - weave_hash: &[u8], - approver: &coven_threads_core::WriterId, - files_touched: &[String], -) -> Result<()> { - append_proposal_decision_audit( - conn, - ProposalDecisionAudit { - event_type: coven_threads_core::AuditEventType::ValidationVerdict, - proposal_id, - familiar_id, - weave_hash, - approver: Some(approver), - files_touched, - decision: "proposal-revalidation-failed", - approval_rationale: None, - }, - ) + let now = time::OffsetDateTime::now_utc(); + for path in scheduled { + let result = (|| -> Result { + let raw = fs::read_to_string(&path) + .with_context(|| format!("reading scheduled proposal {}", path.display()))?; + let mut value: Value = serde_json::from_str(&raw) + .with_context(|| format!("parsing scheduled proposal {}", path.display()))?; + let request = proposal_decision_request(&value)?; + if let Some(object) = value.as_object_mut() { + object.remove("decisionRequest"); + object.remove("decisionState"); + } + let proposal: crate::proposal_scheduler::ScheduledProposal = + serde_json::from_value(value) + .with_context(|| format!("parsing scheduled proposal {}", path.display()))?; + if let Some(request) = request { + let body = request + .rationale + .map(|note| json!({ "note": note }).to_string()); + let response = decide_threads_proposal( + coven_home, + &proposal.pending().id.0.to_string(), + &request.decision, + body.as_deref(), + )?; + return Ok(response.status == 200); + } + ensure_proposal_window_opened_audit(coven_home, &proposal)?; + let due = match &proposal.classification().approval_path { + coven_threads_core::ApprovalPath::AutoRegression { veto: None } => true, + coven_threads_core::ApprovalPath::AutoRegression { veto: Some(_) } + | coven_threads_core::ApprovalPath::FamiliarCoherence { .. } => proposal + .veto_deadline() + .is_some_and(|deadline| now >= deadline), + coven_threads_core::ApprovalPath::HumanApproval + | coven_threads_core::ApprovalPath::HumanApprovalWithRationale => false, + }; + if !due { + return Ok(false); + } + let response = decide_threads_proposal( + coven_home, + &proposal.pending().id.0.to_string(), + "approve", + None, + )?; + Ok(response.status == 200) + })(); + match result { + Ok(true) => completed += 1, + Ok(false) => {} + Err(error) => crate::daemon::append_daemon_recovery_log( + coven_home, + &format!( + "threads scheduler: scheduled proposal failed for {}: {error:#}", + path.display() + ), + ), + } + } + Ok(completed) } -struct ProposalDecisionAudit<'a> { - event_type: coven_threads_core::AuditEventType, - proposal_id: &'a str, - familiar_id: &'a str, - weave_hash: &'a [u8], - approver: Option<&'a coven_threads_core::WriterId>, - files_touched: &'a [String], - decision: &'a str, - approval_rationale: Option<&'a str>, +fn recover_proposal_claim(coven_home: &Path, claim_path: &Path) -> Result { + let name = claim_path + .file_name() + .and_then(|name| name.to_str()) + .context("proposal claim filename is not UTF-8")?; + let (_, suffix) = name + .rsplit_once(".json.") + .context("proposal claim filename lacks decision suffix")?; + let decision = suffix + .strip_suffix(".deciding") + .filter(|decision| matches!(*decision, "approve" | "reject")) + .context("proposal claim has unknown decision")?; + let raw = fs::read_to_string(claim_path) + .with_context(|| format!("reading proposal claim {}", claim_path.display()))?; + let value: Value = serde_json::from_str(&raw).context("parsing proposal recovery claim")?; + let applying = proposal_applying_state(&value)?; + let request = proposal_decision_request(&value)?; + let mut authority = value; + if let Some(object) = authority.as_object_mut() { + object.remove("decisionState"); + object.remove("decisionRequest"); + } + let proposal_id = if is_phase5_proposal_shape(&authority) { + serde_json::from_value::(authority)? + .pending() + .id + .0 + } else { + serde_json::from_value::(authority)? + .id + .0 + }; + let body = applying + .and_then(|state| state.rationale) + .or_else(|| request.and_then(|request| request.rationale)) + .map(|note| json!({ "note": note }).to_string()); + let response = decide_threads_proposal( + coven_home, + &proposal_id.to_string(), + decision, + body.as_deref(), + )?; + Ok(response.status == 200) } -fn append_proposal_decision_audit( - conn: &rusqlite::Connection, - audit: ProposalDecisionAudit<'_>, +fn ensure_proposal_window_opened_audit( + coven_home: &Path, + proposal: &crate::proposal_scheduler::ScheduledProposal, ) -> Result<()> { - let files_touched = serde_json::to_string(audit.files_touched)?; - let detail = match audit.event_type { - coven_threads_core::AuditEventType::ProposalApproved => Some(serde_json::to_string( - &coven_threads_core::ProposalApprovalAuditDetail { - approval_path_label: "human_review".to_string(), - rationale: audit.approval_rationale.map(str::to_string), - window_close: None, - }, - )?), - _ => None, + let (Some(deadline), Some(earliest_close)) = + (proposal.veto_deadline(), proposal.earliest_close()) + else { + return Ok(()); }; - let now = + let pending = proposal.pending(); + let Some(familiar_id) = human_familiar_id_for_weave(coven_home, pending.familiar_id)? else { + anyhow::bail!("scheduled proposal familiar is missing"); + }; + let workspace = crate::cockpit_sources::familiar_workspace(coven_home, &familiar_id); + let config = + ward::WardConfig::load(&workspace)?.context("scheduled proposal Ward is not configured")?; + let targets: Vec = pending + .edits + .iter() + .map(|edit| edit.surface.as_str().to_string()) + .collect(); + let conn = store::open_store(&store_path(coven_home))?; + let state = crate::threads_gate::build_weave_state_for_writer( + &conn, + &familiar_id, + &workspace, + &config, + &targets, + false, + Some(&pending.writer), + )?; + let detail = coven_threads_core::ProposalWindowAuditDetail { + approval_path_label: proposal + .classification() + .approval_path + .display_label() + .to_string(), + deadline, + earliest_close, + evidence_replay_hash_hex: proposal + .classification() + .evidence_replay_hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + affected_regions: proposal + .classification() + .affected_regions + .iter() + .map(|region| region.as_str().to_string()) + .collect(), + }; + let detail = serde_json::to_string(&detail)?; + let files_touched = serde_json::to_string(&targets)?; + let submitted_at = pending + .staged_at + .format(&time::format_description::well_known::Rfc3339)?; + let decided_at = time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?; conn.execute( "INSERT INTO ward_audit ( - event_type, proposal_id, familiar_id, ward_version, ward_hash, - tier, decision, approver, diff_hash, detail, files_touched, - channel, thread_id, submitted_at, decided_at - ) VALUES (?1, ?2, ?3, NULL, ?4, NULL, ?5, ?6, NULL, ?7, ?8, ?9, NULL, ?10, ?10)", + event_type, proposal_id, familiar_id, ward_hash, decision, approver, + detail, files_touched, channel, submitted_at, decided_at + ) + SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 + WHERE NOT EXISTS ( + SELECT 1 FROM ward_audit WHERE proposal_id = ?2 AND event_type = ?1 + )", rusqlite::params![ - audit.event_type.tag(), - audit.proposal_id, - audit.familiar_id, - audit.weave_hash, - audit.decision, - audit.approver.map(|w| w.as_str().to_string()), + coven_threads_core::AuditEventType::ProposalWindowOpened.tag(), + pending.id.0.to_string(), + familiar_id, + state.weave.weave_hash(), + "window-opened", + pending.writer.as_str(), detail, files_touched, - format!("{:?}", coven_threads_core::Channel::Mutation).to_lowercase(), - now, + format!("{:?}", pending.channel).to_lowercase(), + submitted_at, + decided_at, ], ) - .context("appending proposal decision to ward_audit")?; + .context("appending proposal_window_opened audit")?; Ok(()) } -/// Serialize one Ward per-edit outcome for the `/familiars/{id}/edits` response. -fn ward_change_json(change: &crate::ward::AppliedChange) -> Value { - use crate::ward::{Disposition, Verdict}; +struct PendingDecisionClaim { + path: PathBuf, + original_path: PathBuf, + preserve: bool, + recovery: bool, +} - let disposition = match change.disposition { - Disposition::Applied => "applied", - Disposition::HeldForCoherence => "held", - Disposition::Refused => "refused", - }; - let verdict = match &change.decision.verdict { - Verdict::Allow => json!({ "kind": "allow" }), - Verdict::AllowWithLog => json!({ "kind": "allowWithLog" }), - Verdict::RequiresCoherenceReview => json!({ "kind": "requiresCoherenceReview" }), - Verdict::AuthorizedProtectedChange => json!({ "kind": "authorizedProtectedChange" }), - Verdict::Blocked { reason } => { - json!({ "kind": "blocked", "reason": reason.to_string() }) +impl PendingDecisionClaim { + fn acquire( + coven_home: &Path, + proposal_id: Uuid, + decision: &str, + rationale: Option<&str>, + ) -> Result> { + if let Some((path, claimed_decision)) = + find_any_pending_decision_claim(coven_home, &proposal_id.to_string()) + { + if claimed_decision != decision { + anyhow::bail!("proposal is already claimed for {claimed_decision}, not {decision}"); + } + let suffix = format!(".{decision}.deciding"); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .context("proposal decision claim has a non-utf8 filename")?; + let original_name = file_name + .strip_suffix(&suffix) + .context("proposal decision claim has an invalid suffix")?; + return Ok(Some(Self { + original_path: path.with_file_name(original_name), + path, + preserve: false, + recovery: true, + })); } - }; - let mut value = json!({ - "target": change.decision.target, - "resolved": change.decision.resolved, - "tier": u8::from(change.decision.tier), - "verdict": verdict, - "disposition": disposition, - }); - if let Some(audit) = &change.audit { - value["audit"] = serde_json::to_value(audit).unwrap_or(Value::Null); + + let Some(original_path) = find_pending_proposal(coven_home, proposal_id)? else { + return Ok(None); + }; + let file_name = original_path + .file_name() + .and_then(|name| name.to_str()) + .context("pending proposal has a non-utf8 filename")?; + let path = original_path.with_file_name(format!("{file_name}.{decision}.deciding")); + let raw = fs::read_to_string(&original_path) + .with_context(|| format!("reading pending proposal {}", original_path.display()))?; + let mut value: Value = + serde_json::from_str(&raw).context("parsing pending proposal before decision claim")?; + if proposal_decision_request(&value)?.is_none() { + value + .as_object_mut() + .context("pending proposal must be a JSON object")? + .insert( + "decisionRequest".to_string(), + serde_json::to_value(ProposalDecisionRequest { + decision: decision.to_string(), + rationale: rationale.map(str::to_string), + claimed_at: time::OffsetDateTime::now_utc(), + })?, + ); + } + persist_proposal_claim_value(&original_path, &value)?; + fs::rename(&original_path, &path).with_context(|| { + format!( + "claiming pending proposal {} as {}", + original_path.display(), + path.display() + ) + })?; + Ok(Some(Self { + path, + original_path, + preserve: false, + recovery: false, + })) } - value -} -pub(crate) fn parse_body(body: Option<&str>) -> Result { - match body.filter(|body| !body.trim().is_empty()) { - Some(body) => serde_json::from_str(body).context("failed to parse request body"), - None => Ok(json!({})), + fn preserve(&mut self) { + self.preserve = true; } -} -fn split_path_query(path: &str) -> (&str, Option<&str>) { - match path.split_once('?') { - Some((route, query)) => (route, Some(query)), - None => (path, None), + fn consume(&mut self) -> Result<()> { + fs::remove_file(&self.path) + .with_context(|| format!("removing proposal decision claim {}", self.path.display()))?; + self.preserve = true; + Ok(()) } -} -pub(crate) fn query_param<'a>(query: &'a str, key: &str) -> Option<&'a str> { - query.split('&').find_map(|part| { - let (candidate, value) = part.split_once('=')?; - (candidate == key).then_some(value) - }) + fn restore_pending(&mut self, raw_value: &mut Value) -> Result<()> { + raw_value + .as_object_mut() + .context("pending proposal claim must be a JSON object")? + .remove("decisionState"); + raw_value + .as_object_mut() + .context("pending proposal claim must be a JSON object")? + .remove("decisionRequest"); + persist_proposal_claim_value(&self.path, raw_value)?; + fs::rename(&self.path, &self.original_path).with_context(|| { + format!( + "restoring proposal decision claim {} to {}", + self.path.display(), + self.original_path.display() + ) + })?; + self.preserve = true; + Ok(()) + } } -fn session_action_id<'a>(path: &'a str, suffix: &str) -> &'a str { - path.trim_start_matches("/sessions/") - .strip_suffix(suffix) - .unwrap_or_default() +impl Drop for PendingDecisionClaim { + fn drop(&mut self) { + if !self.preserve && self.path.exists() { + let _ = fs::rename(&self.path, &self.original_path); + } + } } -pub(crate) fn current_timestamp() -> String { - Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProposalDecisionRequest { + decision: String, + rationale: Option, + claimed_at: time::OffsetDateTime, } -/// The daemon has no periodic maintenance loop, so the sessions list — the -/// endpoint Cave polls constantly — doubles as the reap tick for rows a dead -/// `coven run` stranded in `created` (#342). Throttled so back-to-back polls -/// don't each pay a write, and best-effort: a failed repair never fails the -/// read it piggybacks on. Startup recovery covers daemons nobody lists. -const STALE_CREATED_REAP_INTERVAL_SECS: u64 = 60; +fn proposal_decision_request(raw_value: &Value) -> Result> { + raw_value + .get("decisionRequest") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("proposal decision request is corrupt") +} -fn reap_stale_created_sessions_throttled(conn: &rusqlite::Connection) { - use std::sync::atomic::{AtomicU64, Ordering}; - static LAST_REAP_EPOCH_SECS: AtomicU64 = AtomicU64::new(0); +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProposalApplyingState { + decision: String, + recovery_commitment: Vec, + weave_hash: Vec, + before_images: Vec, + rationale: Option, +} - let now_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or(0); - let last = LAST_REAP_EPOCH_SECS.load(Ordering::Relaxed); - if now_secs.saturating_sub(last) < STALE_CREATED_REAP_INTERVAL_SECS { - return; - } - if LAST_REAP_EPOCH_SECS - .compare_exchange(last, now_secs, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - // Another connection claimed this tick. - return; - } - let cutoff = (Utc::now() - Duration::seconds(crate::daemon::STALE_CREATED_TTL_SECS)) - .to_rfc3339_opts(SecondsFormat::Nanos, true); - let _ = store::mark_stale_created_sessions_failed(conn, &cutoff, ¤t_timestamp()); +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProposalBeforeImage { + target: String, + contents: coven_threads_core::StagedContents, } -pub(crate) fn api_error( - status: u16, - code: &str, - message: &str, - details: Option, -) -> Result { - let mut error = json!({ - "code": code, - "message": message, - }); - if let Some(d) = details { - error["details"] = d; +fn proposal_recovery_commitment( + conn: &rusqlite::Connection, + config: &ward::WardConfig, + authority_value: &Value, + familiar_id: &str, + targets: &[String], +) -> Result> { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"coven:proposal-decision-recovery:v2"); + let config_bytes = serde_json::to_vec(config).context("serializing Ward config")?; + hasher.update(&(config_bytes.len() as u64).to_be_bytes()); + hasher.update(&config_bytes); + let authority_bytes = + serde_json::to_vec(authority_value).context("serializing proposal authority envelope")?; + hasher.update(&(authority_bytes.len() as u64).to_be_bytes()); + hasher.update(&authority_bytes); + let mut targets = targets.to_vec(); + targets.sort(); + for target in targets { + hasher.update(&(target.len() as u64).to_be_bytes()); + hasher.update(target.as_bytes()); + let baseline = crate::threads_gate::load_baseline(conn, familiar_id, &target)?; + match baseline { + Some(bytes) => { + hasher.update(&[1]); + hasher.update(&(bytes.len() as u64).to_be_bytes()); + hasher.update(&bytes); + } + None => { + hasher.update(&[0]); + } + }; } - json_response(status, &json!({ "error": error })) + Ok(hasher.finalize().as_bytes().to_vec()) } -pub(crate) fn json_response(status: u16, body: &T) -> Result { - Ok(ApiResponse { - status, - content_type: "application/json", - body: serde_json::to_string(body).context("failed to serialize API response")?, - }) +fn proposal_before_images( + workspace: &Path, + targets: &[String], + scheduled: Option<&crate::proposal_scheduler::ScheduledProposal>, +) -> Result> { + targets + .iter() + .map(|target| { + let contents = if let Some(scheduled) = scheduled { + scheduled + .materialized_diff() + .for_surface(&coven_threads_core::SurfaceId::new(target)) + .with_context(|| { + format!("scheduled materialized diff is missing target {target}") + })? + .before + .clone() + .with_context(|| { + format!("scheduled target {target} has no approved before-image") + })? + } else { + crate::threads_gate::read_surface(workspace, target)? + }; + Ok(ProposalBeforeImage { + target: target.clone(), + contents: coven_threads_core::StagedContents::from_bytes(&contents), + }) + }) + .collect() } -#[cfg(test)] -mod tests { - use super::*; +fn proposal_expected_before(applying: &ProposalApplyingState) -> Result>> { + applying + .before_images + .iter() + .map(|before| { + Ok(( + before.target.clone(), + before.contents.to_bytes().map_err(anyhow::Error::msg)?, + )) + }) + .collect() +} - #[test] - fn builds_health_response() { - let response = health_response(None); +fn proposal_expected_after(edits: &[ward::FileEdit]) -> BTreeMap> { + edits + .iter() + .map(|edit| (edit.target.clone(), edit.new_contents.clone())) + .collect() +} - assert!(response.ok); - assert_eq!(response.api_version, COVEN_API_NAMED_VERSION); - assert_eq!(response.coven_version, COVEN_VERSION); - assert!(response.capabilities.sessions); - assert!(response.capabilities.events); - assert!(response.capabilities.travel); - assert!(response.capabilities.scheduler); - assert!(response.capabilities.hub); - assert!(response.capabilities.executor_dispatch); - assert_eq!(response.capabilities.event_cursor, "sequence"); - assert!(response.capabilities.structured_errors); - assert_eq!(response.daemon, None); - assert_eq!(response.hub, None); +fn approved_bytes_by_resolved( + report: &ward::ApplyReport, + edits: &[ward::FileEdit], +) -> Result>> { + if report.changes.len() != edits.len() { + anyhow::bail!("Ward apply report length does not match approved edits"); } + report + .changes + .iter() + .zip(edits) + .map(|(change, edit)| Ok((change.decision.resolved.clone(), edit.new_contents.clone()))) + .collect() +} - #[test] - fn routes_health_request_to_json() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - let daemon = DaemonStatus { - pid: 12345, - started_at: "2026-04-27T10:00:00Z".to_string(), - socket: temp_dir - .path() - .join("coven.sock") - .to_string_lossy() - .into_owned(), - }; +fn proposal_rollback_edits(applying: &ProposalApplyingState) -> Result> { + applying + .before_images + .iter() + .map(|before| { + Ok(ward::FileEdit::new( + before.target.clone(), + before.contents.to_bytes().map_err(anyhow::Error::msg)?, + )) + }) + .collect() +} - let response = handle_request("GET", "/health", temp_dir.path(), Some(daemon))?; +fn persist_proposal_applying_state( + claim_path: &Path, + raw_value: &mut Value, + state: &ProposalApplyingState, +) -> Result<()> { + raw_value + .as_object_mut() + .context("pending proposal claim must be a JSON object")? + .insert( + "decisionState".to_string(), + serde_json::to_value(state).context("serializing proposal applying state")?, + ); + persist_proposal_claim_value(claim_path, raw_value) +} - assert_eq!(response.status, 200); - assert_eq!(response.content_type, "application/json"); - assert!(response.body.contains(r#""ok":true"#)); - assert!(response.body.contains(r#""apiVersion":"coven.daemon.v1""#)); - assert!(response.body.contains(r#""pid":12345"#)); - assert!(response.body.contains(r#""sessions":true"#)); - assert!(response.body.contains(r#""travel":true"#)); - assert!(response.body.contains(r#""scheduler":true"#)); - assert!(response.body.contains(r#""hub":true"#)); - assert!(response.body.contains(r#""executorDispatch":true"#)); - assert!(response.body.contains(r#""role":"hub""#)); - assert!(response.body.contains(r#""structuredErrors":true"#)); - Ok(()) +fn persist_proposal_claim_value(claim_path: &Path, raw_value: &Value) -> Result<()> { + let body = serde_json::to_vec_pretty(raw_value).context("serializing proposal claim")?; + let file_name = claim_path + .file_name() + .and_then(|name| name.to_str()) + .context("proposal decision claim has a non-utf8 filename")?; + let staged = claim_path.with_file_name(format!(".{file_name}.{}.staged", Uuid::new_v4())); + let write_result = (|| -> Result<()> { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&staged) + .with_context(|| format!("creating proposal claim stage {}", staged.display()))?; + file.write_all(&body) + .with_context(|| format!("writing proposal claim stage {}", staged.display()))?; + file.sync_all() + .with_context(|| format!("syncing proposal claim stage {}", staged.display()))?; + fs::rename(&staged, claim_path).with_context(|| { + format!( + "committing proposal applying state {}", + claim_path.display() + ) + }) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&staged); } + write_result +} - #[test] - fn routes_versioned_health_request_to_named_api_contract() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; +fn proposal_applying_state(raw_value: &Value) -> Result> { + raw_value + .get("decisionState") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("invalid proposal applying state") +} - let response = handle_request("GET", "/api/v1/health", temp_dir.path(), None)?; +fn append_proposal_apply_intent( + conn: &rusqlite::Connection, + proposal_id: &str, + familiar_id: &str, + approver: &coven_threads_core::WriterId, + files_touched: &[String], + state: &ProposalApplyingState, + channel: coven_threads_core::Channel, +) -> Result<()> { + let detail = serde_json::to_string(state).context("serializing proposal apply intent")?; + let files_touched = serde_json::to_string(files_touched)?; + let now = + time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?; + conn.execute( + "INSERT INTO ward_audit ( + event_type, proposal_id, familiar_id, ward_version, ward_hash, + tier, decision, approver, diff_hash, detail, files_touched, + channel, thread_id, submitted_at, decided_at + ) VALUES ( + 'validation_verdict', ?1, ?2, NULL, ?3, NULL, + 'proposal-apply-intent', ?4, NULL, ?5, ?6, ?7, NULL, ?8, ?8 + )", + rusqlite::params![ + proposal_id, + familiar_id, + state.weave_hash, + approver.as_str(), + detail, + files_touched, + format!("{channel:?}").to_lowercase(), + now, + ], + ) + .context("appending proposal apply intent")?; + Ok(()) +} - assert_eq!(response.status, 200); - assert!(response.body.contains(r#""apiVersion":"coven.daemon.v1""#)); - assert!(response.body.contains(r#""covenVersion""#)); - assert!(response.body.contains(r#""capabilities""#)); - assert!(response.body.contains(r#""eventCursor":"sequence""#)); - assert!(response.body.contains(r#""ok":true"#)); - Ok(()) - } +fn load_proposal_apply_intent( + conn: &rusqlite::Connection, + proposal_id: &str, +) -> Result> { + use rusqlite::OptionalExtension; - #[test] - fn travel_profile_generation_returns_compressed_read_only_profile_metadata( - ) -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; + let detail: Option = conn + .query_row( + "SELECT detail + FROM ward_audit + WHERE proposal_id = ?1 + AND event_type = 'validation_verdict' + AND decision = 'proposal-apply-intent' + ORDER BY id DESC + LIMIT 1", + [proposal_id], + |row| row.get(0), + ) + .optional() + .context("loading proposal apply intent")?; + detail + .map(|detail| serde_json::from_str(&detail).context("invalid proposal apply intent")) + .transpose() +} - let response = handle_request_with_body( - "POST", - "/api/v1/travel/profiles", - temp_dir.path(), - None, - Some( - r#"{ - "familiarId":"sage", - "workspaceId":"workspace-1", +fn verify_recoverable_apply_state( + workspace: &Path, + pending: &coven_threads_core::PendingProposal, + applying: &ProposalApplyingState, +) -> Result<()> { + let staged: std::collections::BTreeMap<&str, Vec> = pending + .edits + .iter() + .map(|edit| { + Ok(( + edit.surface.as_str(), + edit.contents.to_bytes().map_err(anyhow::Error::msg)?, + )) + }) + .collect::>()?; + let mut before_images = std::collections::BTreeMap::new(); + for before in &applying.before_images { + if before_images + .insert(before.target.as_str(), &before.contents) + .is_some() + { + anyhow::bail!("duplicate before image for {}", before.target); + } + } + if before_images.keys().copied().collect::>() + != staged.keys().copied().collect::>() + { + anyhow::bail!("proposal before images do not match staged targets"); + } + for before in &applying.before_images { + let current = crate::threads_gate::read_surface(workspace, &before.target)?; + let before_bytes = before.contents.to_bytes().map_err(anyhow::Error::msg)?; + let after_bytes = staged + .get(before.target.as_str()) + .with_context(|| format!("missing staged contents for {}", before.target))?; + if current != before_bytes && current != *after_bytes { + anyhow::bail!( + "surface {} diverged from both before and staged contents during recovery", + before.target + ); + } + } + Ok(()) +} + +struct ProposalTerminalAudit { + event_type: String, + files_touched: Vec, +} + +fn terminal_decision_label(event_type: &str) -> &'static str { + match event_type { + "proposal_approved" => "approved", + "proposal_vetoed" => "vetoed", + _ => "rejected", + } +} + +fn proposal_terminal_event( + conn: &rusqlite::Connection, + proposal_id: &str, +) -> Result> { + use rusqlite::OptionalExtension; + + let row: Option<(String, String)> = conn + .query_row( + "SELECT event_type, files_touched + FROM ward_audit + WHERE proposal_id = ?1 + AND event_type IN ('proposal_approved', 'proposal_rejected', 'proposal_vetoed') + ORDER BY id DESC + LIMIT 1", + [proposal_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .context("loading proposal terminal audit event")?; + row.map(|(event_type, files_touched)| { + Ok(ProposalTerminalAudit { + event_type, + files_touched: serde_json::from_str(&files_touched) + .context("terminal audit files_touched is invalid")?, + }) + }) + .transpose() +} + +fn find_any_pending_decision_claim( + coven_home: &Path, + proposal_id: &str, +) -> Option<(PathBuf, String)> { + let pending_dir = coven_home.join("pending"); + let entries = fs::read_dir(pending_dir).ok()?; + let marker = format!("-{proposal_id}.json."); + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some((_, suffix)) = name.split_once(&marker) else { + continue; + }; + let Some(decision) = suffix.strip_suffix(".deciding").map(str::to_string) else { + continue; + }; + if matches!(decision.as_str(), "approve" | "reject") { + return Some((path, decision)); + } + } + None +} + +#[cfg(test)] +fn find_pending_decision_claim( + coven_home: &Path, + proposal_id: &str, + decision: &str, +) -> Option { + find_any_pending_decision_claim(coven_home, proposal_id) + .filter(|(_, claimed_decision)| claimed_decision == decision) + .map(|(path, _)| path) +} + +fn cleanup_terminal_proposal_artifacts(coven_home: &Path, proposal_id: Uuid) -> Result<()> { + if let Some((claim, _)) = find_any_pending_decision_claim(coven_home, &proposal_id.to_string()) + { + fs::remove_file(&claim) + .with_context(|| format!("removing terminal proposal claim {}", claim.display()))?; + } + if let Some(pending) = find_pending_proposal(coven_home, proposal_id)? { + fs::remove_file(&pending).with_context(|| { + format!( + "removing terminal proposal pending file {}", + pending.display() + ) + })?; + } + Ok(()) +} + +fn human_familiar_id_for_weave( + coven_home: &Path, + familiar_uuid: coven_threads_core::FamiliarId, +) -> Result> { + for familiar in crate::cockpit_sources::read_familiars(coven_home)? { + if crate::threads_gate::familiar_weave_id(&familiar.id) == familiar_uuid { + return Ok(Some(familiar.id)); + } + } + Ok(None) +} + +fn authorization_from_writer(writer: &coven_threads_core::WriterId) -> ward::Authorization { + writer + .as_str() + .strip_prefix("principal:") + .map(|fp| ward::Authorization::signed_by(fp.to_string())) + .unwrap_or_else(ward::Authorization::unsigned) +} + +fn ward_config_is_unchanged(workspace: &Path, expected: &ward::WardConfig) -> Result { + Ok(ward::WardConfig::load(workspace)?.as_ref() == Some(expected)) +} + +fn staged_edits_to_ward_edits( + pending: &coven_threads_core::PendingProposal, +) -> Result> { + pending + .edits + .iter() + .map(|edit| { + let bytes = edit + .contents + .to_bytes() + .map_err(|err| anyhow::anyhow!("decoding staged contents: {err}"))?; + let contents = String::from_utf8(bytes) + .context("staged proposal contents are not utf8; Ward FileEdit is text-only")?; + Ok(ward::FileEdit::new(edit.surface.as_str(), contents)) + }) + .collect() +} + +fn append_proposal_refusal_audit( + conn: &rusqlite::Connection, + proposal_id: &str, + familiar_id: &str, + weave_hash: &[u8], + approver: &coven_threads_core::WriterId, + files_touched: &[String], + channel: coven_threads_core::Channel, +) -> Result<()> { + append_proposal_decision_audit( + conn, + ProposalDecisionAudit { + event_type: coven_threads_core::AuditEventType::ValidationVerdict, + proposal_id, + familiar_id, + weave_hash, + approver: Some(approver), + files_touched, + decision: "proposal-revalidation-failed", + approval_rationale: None, + approval_path_label: "human_review", + window_close: None, + channel, + }, + ) +} + +struct ProposalDecisionAudit<'a> { + event_type: coven_threads_core::AuditEventType, + proposal_id: &'a str, + familiar_id: &'a str, + weave_hash: &'a [u8], + approver: Option<&'a coven_threads_core::WriterId>, + files_touched: &'a [String], + decision: &'a str, + approval_rationale: Option<&'a str>, + approval_path_label: &'a str, + window_close: Option<&'a coven_threads_core::ProposalWindowCloseAuditDetail>, + channel: coven_threads_core::Channel, +} + +struct ApprovedProposalFinalization<'a> { + proposal_id: &'a str, + familiar_id: &'a str, + workspace: &'a Path, + weave_hash: &'a [u8], + approver: &'a coven_threads_core::WriterId, + gated_targets: &'a [String], + approved_bytes: &'a BTreeMap>, + files_touched: &'a [String], + rationale: Option<&'a str>, + approval_path_label: &'a str, + window_close: Option<&'a coven_threads_core::ProposalWindowCloseAuditDetail>, + channel: coven_threads_core::Channel, +} + +fn finalize_approved_proposal( + conn: &rusqlite::Connection, + finalization: ApprovedProposalFinalization<'_>, +) -> Result<()> { + conn.execute_batch("BEGIN IMMEDIATE") + .context("starting proposal approval transaction")?; + let result = (|| -> Result<()> { + for target in finalization.gated_targets { + let expected_bytes = finalization + .approved_bytes + .get(target) + .with_context(|| format!("approved target {target} is missing staged contents"))?; + crate::threads_gate::advance_surface_baseline_from_bytes( + conn, + finalization.familiar_id, + finalization.workspace, + target, + expected_bytes, + )?; + } + append_proposal_decision_audit( + conn, + ProposalDecisionAudit { + event_type: coven_threads_core::AuditEventType::ProposalApproved, + proposal_id: finalization.proposal_id, + familiar_id: finalization.familiar_id, + weave_hash: finalization.weave_hash, + approver: Some(finalization.approver), + files_touched: finalization.files_touched, + decision: "approved", + approval_rationale: finalization.rationale, + approval_path_label: finalization.approval_path_label, + window_close: finalization.window_close, + channel: finalization.channel, + }, + )?; + conn.execute_batch("COMMIT") + .context("committing proposal approval transaction") + })(); + if result.is_err() { + let _ = conn.execute_batch("ROLLBACK"); + } + result +} + +fn append_proposal_decision_audit( + conn: &rusqlite::Connection, + audit: ProposalDecisionAudit<'_>, +) -> Result<()> { + let files_touched = serde_json::to_string(audit.files_touched)?; + let detail = match audit.event_type { + coven_threads_core::AuditEventType::ProposalApproved => Some(serde_json::to_string( + &coven_threads_core::ProposalApprovalAuditDetail { + approval_path_label: audit.approval_path_label.to_string(), + rationale: audit.approval_rationale.map(str::to_string), + window_close: audit.window_close.cloned(), + }, + )?), + coven_threads_core::AuditEventType::ProposalVetoed => { + audit.window_close.map(serde_json::to_string).transpose()? + } + _ => None, + }; + let now = + time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?; + conn.execute( + "INSERT INTO ward_audit ( + event_type, proposal_id, familiar_id, ward_version, ward_hash, + tier, decision, approver, diff_hash, detail, files_touched, + channel, thread_id, submitted_at, decided_at + ) VALUES (?1, ?2, ?3, NULL, ?4, NULL, ?5, ?6, NULL, ?7, ?8, ?9, NULL, ?10, ?10)", + rusqlite::params![ + audit.event_type.tag(), + audit.proposal_id, + audit.familiar_id, + audit.weave_hash, + audit.decision, + audit.approver.map(|w| w.as_str().to_string()), + detail, + files_touched, + format!("{:?}", audit.channel).to_lowercase(), + now, + ], + ) + .context("appending proposal decision to ward_audit")?; + Ok(()) +} + +/// Serialize one Ward per-edit outcome for the `/familiars/{id}/edits` response. +fn ward_change_json(change: &crate::ward::AppliedChange) -> Value { + use crate::ward::{Disposition, Verdict}; + + let disposition = match change.disposition { + Disposition::Applied => "applied", + Disposition::HeldForCoherence => "held", + Disposition::Refused => "refused", + }; + let verdict = match &change.decision.verdict { + Verdict::Allow => json!({ "kind": "allow" }), + Verdict::AllowWithLog => json!({ "kind": "allowWithLog" }), + Verdict::RequiresCoherenceReview => json!({ "kind": "requiresCoherenceReview" }), + Verdict::AuthorizedProtectedChange => json!({ "kind": "authorizedProtectedChange" }), + Verdict::Blocked { reason } => { + json!({ "kind": "blocked", "reason": reason.to_string() }) + } + }; + let mut value = json!({ + "target": change.decision.target, + "resolved": change.decision.resolved, + "tier": u8::from(change.decision.tier), + "verdict": verdict, + "disposition": disposition, + }); + if let Some(audit) = &change.audit { + value["audit"] = serde_json::to_value(audit).unwrap_or(Value::Null); + } + value +} + +pub(crate) fn parse_body(body: Option<&str>) -> Result { + match body.filter(|body| !body.trim().is_empty()) { + Some(body) => serde_json::from_str(body).context("failed to parse request body"), + None => Ok(json!({})), + } +} + +fn split_path_query(path: &str) -> (&str, Option<&str>) { + match path.split_once('?') { + Some((route, query)) => (route, Some(query)), + None => (path, None), + } +} + +pub(crate) fn query_param<'a>(query: &'a str, key: &str) -> Option<&'a str> { + query.split('&').find_map(|part| { + let (candidate, value) = part.split_once('=')?; + (candidate == key).then_some(value) + }) +} + +fn session_action_id<'a>(path: &'a str, suffix: &str) -> &'a str { + path.trim_start_matches("/sessions/") + .strip_suffix(suffix) + .unwrap_or_default() +} + +pub(crate) fn current_timestamp() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true) +} + +/// The daemon has no periodic maintenance loop, so the sessions list — the +/// endpoint Cave polls constantly — doubles as the reap tick for rows a dead +/// `coven run` stranded in `created` (#342). Throttled so back-to-back polls +/// don't each pay a write, and best-effort: a failed repair never fails the +/// read it piggybacks on. Startup recovery covers daemons nobody lists. +const STALE_CREATED_REAP_INTERVAL_SECS: u64 = 60; + +fn reap_stale_created_sessions_throttled(conn: &rusqlite::Connection) { + use std::sync::atomic::{AtomicU64, Ordering}; + static LAST_REAP_EPOCH_SECS: AtomicU64 = AtomicU64::new(0); + + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0); + let last = LAST_REAP_EPOCH_SECS.load(Ordering::Relaxed); + if now_secs.saturating_sub(last) < STALE_CREATED_REAP_INTERVAL_SECS { + return; + } + if LAST_REAP_EPOCH_SECS + .compare_exchange(last, now_secs, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + // Another connection claimed this tick. + return; + } + let cutoff = (Utc::now() - Duration::seconds(crate::daemon::STALE_CREATED_TTL_SECS)) + .to_rfc3339_opts(SecondsFormat::Nanos, true); + let _ = store::mark_stale_created_sessions_failed(conn, &cutoff, ¤t_timestamp()); +} + +pub(crate) fn api_error( + status: u16, + code: &str, + message: &str, + details: Option, +) -> Result { + let mut error = json!({ + "code": code, + "message": message, + }); + if let Some(d) = details { + error["details"] = d; + } + json_response(status, &json!({ "error": error })) +} + +pub(crate) fn json_response(status: u16, body: &T) -> Result { + Ok(ApiResponse { + status, + content_type: "application/json", + body: serde_json::to_string(body).context("failed to serialize API response")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_health_response() { + let response = health_response(None); + + assert!(response.ok); + assert_eq!(response.api_version, COVEN_API_NAMED_VERSION); + assert_eq!(response.coven_version, COVEN_VERSION); + assert!(response.capabilities.sessions); + assert!(response.capabilities.events); + assert!(response.capabilities.travel); + assert!(response.capabilities.scheduler); + assert!(response.capabilities.hub); + assert!(response.capabilities.executor_dispatch); + assert_eq!(response.capabilities.event_cursor, "sequence"); + assert!(response.capabilities.structured_errors); + assert_eq!(response.daemon, None); + assert_eq!(response.hub, None); + } + + #[test] + fn routes_health_request_to_json() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let daemon = DaemonStatus { + pid: 12345, + started_at: "2026-04-27T10:00:00Z".to_string(), + socket: temp_dir + .path() + .join("coven.sock") + .to_string_lossy() + .into_owned(), + }; + + let response = handle_request("GET", "/health", temp_dir.path(), Some(daemon))?; + + assert_eq!(response.status, 200); + assert_eq!(response.content_type, "application/json"); + assert!(response.body.contains(r#""ok":true"#)); + assert!(response.body.contains(r#""apiVersion":"coven.daemon.v1""#)); + assert!(response.body.contains(r#""pid":12345"#)); + assert!(response.body.contains(r#""sessions":true"#)); + assert!(response.body.contains(r#""travel":true"#)); + assert!(response.body.contains(r#""scheduler":true"#)); + assert!(response.body.contains(r#""hub":true"#)); + assert!(response.body.contains(r#""executorDispatch":true"#)); + assert!(response.body.contains(r#""role":"hub""#)); + assert!(response.body.contains(r#""structuredErrors":true"#)); + Ok(()) + } + + #[test] + fn routes_versioned_health_request_to_named_api_contract() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + let response = handle_request("GET", "/api/v1/health", temp_dir.path(), None)?; + + assert_eq!(response.status, 200); + assert!(response.body.contains(r#""apiVersion":"coven.daemon.v1""#)); + assert!(response.body.contains(r#""covenVersion""#)); + assert!(response.body.contains(r#""capabilities""#)); + assert!(response.body.contains(r#""eventCursor":"sequence""#)); + assert!(response.body.contains(r#""ok":true"#)); + Ok(()) + } + + #[test] + fn travel_profile_generation_returns_compressed_read_only_profile_metadata( + ) -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + let response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some( + r#"{ + "familiarId":"sage", + "workspaceId":"workspace-1", "expiresInSeconds":604800, "staleAfterSeconds":172800, "includeContext":["memory","workspace","policy"] @@ -3624,2038 +5139,2585 @@ mod tests { assert_eq!(response.status, 201); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["version"], "0.1"); - assert!(body["profileId"].as_str().unwrap().starts_with("travel_")); - assert_eq!(body["scope"]["familiarId"], "sage"); - assert_eq!(body["scope"]["workspaceId"], "workspace-1"); - assert_eq!(body["permissions"]["mode"], "travel-read-only"); - assert_eq!(body["permissions"]["allowMemoryOverwrite"], false); - assert_eq!(body["permissions"]["allowHeavyweightLocalWork"], false); - assert_eq!(body["encoding"], "gzip+base64"); - assert!(body["profileBlob"].as_str().unwrap().len() > 16); - assert!(body["contentHash"].as_str().unwrap().starts_with("sha256:")); - assert!(body["generatedAt"].as_str().unwrap().contains('T')); - assert!(body["sourceHub"]["hubId"] + assert_eq!(body["version"], "0.1"); + assert!(body["profileId"].as_str().unwrap().starts_with("travel_")); + assert_eq!(body["scope"]["familiarId"], "sage"); + assert_eq!(body["scope"]["workspaceId"], "workspace-1"); + assert_eq!(body["permissions"]["mode"], "travel-read-only"); + assert_eq!(body["permissions"]["allowMemoryOverwrite"], false); + assert_eq!(body["permissions"]["allowHeavyweightLocalWork"], false); + assert_eq!(body["encoding"], "gzip+base64"); + assert!(body["profileBlob"].as_str().unwrap().len() > 16); + assert!(body["contentHash"].as_str().unwrap().starts_with("sha256:")); + assert!(body["generatedAt"].as_str().unwrap().contains('T')); + assert!(body["sourceHub"]["hubId"] + .as_str() + .unwrap() + .starts_with("hub_")); + assert!(body["expiresAt"].as_str().unwrap() > body["generatedAt"].as_str().unwrap()); + assert!(body["staleAfter"].as_str().unwrap() > body["generatedAt"].as_str().unwrap()); + Ok(()) + } + + #[test] + fn travel_profile_generation_reuses_stable_source_hub_identity() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + let first = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let second = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + + let first: serde_json::Value = serde_json::from_str(&first.body)?; + let second: serde_json::Value = serde_json::from_str(&second.body)?; + assert_eq!(first["sourceHub"]["hubId"], second["sourceHub"]["hubId"]); + assert_ne!(first["profileId"], second["profileId"]); + Ok(()) + } + + #[test] + fn travel_profile_generation_embeds_familiar_memory_context_and_readonly_artifact( + ) -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let memory_dir = temp_dir.path().join("memory").join("sage"); + std::fs::create_dir_all(&memory_dir)?; + std::fs::write( + memory_dir.join("field-notes.md"), + "# Field notes\n\nSage remembers the travel-mode acceptance criteria.", + )?; + + let response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + + assert_eq!(response.status, 201); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + let profile_id = body["profileId"].as_str().unwrap(); + let blob = body["profileBlob"].as_str().unwrap(); + let compressed = base64::engine::general_purpose::STANDARD.decode(blob)?; + let mut decoder = flate2::read::GzDecoder::new(&compressed[..]); + let mut decoded = String::new(); + std::io::Read::read_to_string(&mut decoder, &mut decoded)?; + let profile: serde_json::Value = serde_json::from_str(&decoded)?; + assert_eq!( + profile["payload"]["memoryContext"][0]["path"], + "sage/field-notes.md" + ); + assert_eq!( + profile["payload"]["memoryContext"][0]["excerpt"], + "Sage remembers the travel-mode acceptance criteria." + ); + + let artifact = temp_dir + .path() + .join("travel") + .join("profiles") + .join(format!("{profile_id}.json.gz")); + assert!(artifact.exists(), "missing {}", artifact.display()); + assert!( + artifact.metadata()?.permissions().readonly(), + "travel profile artifact should be read-only" + ); + Ok(()) + } + + #[test] + fn travel_delta_upload_appends_results_without_overwriting_canonical_memory( + ) -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let profile_response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; + let profile_id = profile["profileId"].as_str().unwrap(); + let source_hub_id = profile["sourceHub"]["hubId"].as_str().unwrap(); + let memory_revision = profile["sourceRevision"]["memoryRevision"] + .as_str() + .unwrap(); + let loop_revision = profile["sourceRevision"]["loopRevision"].as_str().unwrap(); + let delta_body = serde_json::json!({ + "profileId": profile_id, + "sourceHubId": source_hub_id, + "sourceRevision": { + "memoryRevision": memory_revision, + "loopRevision": loop_revision + }, + "clientId": "laptop-1", + "events": [{"id":"event-1","kind":"assistant","text":"offline result"}], + "artifacts": [{"id":"artifact-1","kind":"summary"}], + "proposedMemoryAdditions": [{"path":"MEMORY.md","text":"append this"}], + "canonicalMemoryOverwrite": {"path":"MEMORY.md","text":"replace everything"} + }); + + let response = handle_request_with_body( + "POST", + "/api/v1/travel/deltas", + temp_dir.path(), + None, + Some(&delta_body.to_string()), + )?; + + assert_eq!(response.status, 202); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert!(body["deltaId"].as_str().unwrap().starts_with("delta_")); + assert_eq!(body["state"], "hub_resumed"); + assert_eq!(body["acceptedEvents"], 1); + assert_eq!(body["acceptedArtifacts"], 1); + assert_eq!(body["memoryReviewState"], "queued"); + assert_eq!(body["canonicalMemoryOverwriteApplied"], false); + assert!(body["hubRevision"]["memoryRevision"] + .as_str() + .unwrap() + .starts_with("mem_")); + Ok(()) + } + + #[test] + fn travel_delta_upload_rejects_expired_profiles() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let profile_response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; + let profile_id = profile["profileId"].as_str().unwrap(); + let conn = store::open_store(&store_path(temp_dir.path()))?; + conn.execute( + "UPDATE travel_profiles SET expires_at = '2020-01-01T00:00:00Z' WHERE id = ?1", + [profile_id], + )?; + drop(conn); + + let response = handle_request_with_body( + "POST", + "/api/v1/travel/deltas", + temp_dir.path(), + None, + Some( + &serde_json::json!({ + "profileId": profile_id, + "sourceHubId": profile["sourceHub"]["hubId"], + "clientId": "laptop-1", + "events": [{"id":"event-1","kind":"assistant","text":"offline result"}] + }) + .to_string(), + ), + )?; + + assert_eq!(response.status, 409); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "travel_profile_expired"); + Ok(()) + } + + #[test] + fn travel_state_reports_stale_profile_before_handoff() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let profile_response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; + let profile_id = profile["profileId"].as_str().unwrap(); + let conn = store::open_store(&store_path(temp_dir.path()))?; + conn.execute( + "UPDATE travel_profiles SET stale_after = '2020-01-01T00:00:00Z' WHERE id = ?1", + [profile_id], + )?; + drop(conn); + + let response = handle_request( + "GET", + &format!("/api/v1/travel/state?clientId=laptop-1&profileId={profile_id}"), + temp_dir.path(), + None, + )?; + + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["state"], "travel_stale"); + assert_eq!(body["profileId"], profile_id); + assert_eq!(body["profileFreshness"], "stale"); + assert_eq!(body["hubReachable"], false); + Ok(()) + } + + #[test] + fn travel_state_refuses_local_execution_for_expired_profile() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let profile_response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; + let profile_id = profile["profileId"].as_str().unwrap(); + let conn = store::open_store(&store_path(temp_dir.path()))?; + conn.execute( + "UPDATE travel_profiles SET expires_at = '2020-01-01T00:00:00Z' WHERE id = ?1", + [profile_id], + )?; + drop(conn); + + let response = handle_request( + "GET", + &format!("/api/v1/travel/state?clientId=laptop-1&profileId={profile_id}"), + temp_dir.path(), + None, + )?; + + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["state"], "travel_stale"); + assert_eq!(body["profileFreshness"], "expired"); + assert_eq!(body["travelExecutionAllowed"], false); + Ok(()) + } + + #[test] + fn travel_delta_upload_appends_offline_events_to_canonical_event_log() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let profile_response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; + let response = handle_request_with_body( + "POST", + "/api/v1/travel/deltas", + temp_dir.path(), + None, + Some( + &serde_json::json!({ + "profileId": profile["profileId"], + "sourceHubId": profile["sourceHub"]["hubId"], + "clientId": "laptop-1", + "events": [ + {"id":"local-event-1","kind":"assistant","text":"offline result"} + ] + }) + .to_string(), + ), + )?; + + assert_eq!(response.status, 202); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + let session_id = body["reconciliationSessionId"].as_str().unwrap(); + let events = handle_request( + "GET", + &format!("/api/v1/events?sessionId={session_id}"), + temp_dir.path(), + None, + )?; + assert_eq!(events.status, 200); + let events_body: serde_json::Value = serde_json::from_str(&events.body)?; + assert_eq!(events_body["events"][0]["kind"], "travel.offline_event"); + assert!(events_body["events"][0]["payload_json"] + .as_str() + .unwrap() + .contains("offline result")); + Ok(()) + } + + #[test] + fn travel_state_exposes_handoff_states_for_clients() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + let initial = handle_request( + "GET", + "/api/v1/travel/state?clientId=laptop-1", + temp_dir.path(), + None, + )?; + + assert_eq!(initial.status, 200); + let body: serde_json::Value = serde_json::from_str(&initial.body)?; + assert_eq!(body["state"], "hub_active"); + assert_eq!(body["hubReachable"], true); + assert_eq!(body["pendingDeltaBytes"], 0); + + let profile_response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; + let pending_delta = serde_json::json!({ + "profileId": profile["profileId"], + "sourceHubId": profile["sourceHub"]["hubId"], + "sourceRevision": profile["sourceRevision"], + "clientId": "laptop-1", + "events": [{"id":"event-1","kind":"assistant","text":"offline result"}] + }); + let _ = handle_request_with_body( + "POST", + "/api/v1/travel/deltas?defer=1", + temp_dir.path(), + None, + Some(&pending_delta.to_string()), + )?; + + let pending = handle_request( + "GET", + "/api/v1/travel/state?clientId=laptop-1", + temp_dir.path(), + None, + )?; + + assert_eq!(pending.status, 200); + let body: serde_json::Value = serde_json::from_str(&pending.body)?; + assert_eq!(body["state"], "handoff_pending"); + assert_eq!(body["profileId"], profile["profileId"]); + assert!(body["pendingDeltaBytes"].as_i64().unwrap() > 0); + assert_eq!(body["profileFreshness"], "fresh"); + Ok(()) + } + + #[test] + fn travel_failure_simulation_reconnect_walks_handoff_sync_and_resume_states( + ) -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let profile_response = handle_request_with_body( + "POST", + "/api/v1/travel/profiles", + temp_dir.path(), + None, + Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + )?; + let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; + let profile_id = profile["profileId"].as_str().unwrap(); + + let local = handle_request( + "GET", + &format!("/api/v1/travel/state?clientId=laptop-1&profileId={profile_id}"), + temp_dir.path(), + None, + )?; + let local_body: serde_json::Value = serde_json::from_str(&local.body)?; + assert_eq!(local_body["state"], "travel_local"); + assert_eq!(local_body["hubReachable"], false); + + let delta = serde_json::json!({ + "profileId": profile["profileId"], + "sourceHubId": profile["sourceHub"]["hubId"], + "sourceRevision": profile["sourceRevision"], + "clientId": "laptop-1", + "events": [{"id":"event-1","kind":"assistant","text":"offline result"}] + }); + let handoff = handle_request_with_body( + "POST", + "/api/v1/travel/deltas?state=handoff_pending", + temp_dir.path(), + None, + Some(&delta.to_string()), + )?; + assert_eq!(handoff.status, 202); + let handoff_body: serde_json::Value = serde_json::from_str(&handoff.body)?; + assert_eq!(handoff_body["state"], "handoff_pending"); + + let syncing = handle_request_with_body( + "POST", + "/api/v1/travel/deltas?state=syncing_delta", + temp_dir.path(), + None, + Some(&delta.to_string()), + )?; + assert_eq!(syncing.status, 202); + let syncing_body: serde_json::Value = serde_json::from_str(&syncing.body)?; + assert_eq!(syncing_body["state"], "syncing_delta"); + + let syncing_state = handle_request( + "GET", + "/api/v1/travel/state?clientId=laptop-1", + temp_dir.path(), + None, + )?; + let syncing_state_body: serde_json::Value = serde_json::from_str(&syncing_state.body)?; + assert_eq!(syncing_state_body["state"], "syncing_delta"); + assert_eq!(syncing_state_body["hubReachable"], true); + + let resumed = handle_request_with_body( + "POST", + "/api/v1/travel/deltas", + temp_dir.path(), + None, + Some(&delta.to_string()), + )?; + assert_eq!(resumed.status, 202); + let resumed_body: serde_json::Value = serde_json::from_str(&resumed.body)?; + assert_eq!(resumed_body["state"], "hub_resumed"); + Ok(()) + } + + #[test] + fn scheduler_decision_selects_available_executor_by_capability_and_queue_pressure( + ) -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let request = serde_json::json!({ + "jobId": "job-gpu-loop", + "requiredCapabilities": ["gpu", "long-running-loop"], + "taskWeight": "heavyweight", + "travelState": "hub_active", + "nodes": [ + { + "nodeId": "node-stationary", + "role": "stationary_executor", + "available": true, + "capabilities": ["shell"], + "queuePressure": 0 + }, + { + "nodeId": "node-compute-busy", + "role": "compute_executor", + "available": true, + "capabilities": ["gpu", "long-running-loop"], + "queuePressure": 8 + }, + { + "nodeId": "node-compute-idle", + "role": "compute_executor", + "available": true, + "capabilities": ["gpu", "long-running-loop"], + "queuePressure": 1 + } + ] + }); + + let response = handle_request_with_body( + "POST", + "/api/v1/scheduler/decisions", + temp_dir.path(), + None, + Some(&request.to_string()), + )?; + + assert_eq!(response.status, 201); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert!(body["decisionId"].as_str().unwrap().starts_with("sched_")); + assert_eq!(body["jobId"], "job-gpu-loop"); + assert_eq!(body["target"]["role"], "compute_executor"); + assert_eq!(body["target"]["nodeId"], "node-compute-idle"); + assert!(body["reason"] .as_str() .unwrap() - .starts_with("hub_")); - assert!(body["expiresAt"].as_str().unwrap() > body["generatedAt"].as_str().unwrap()); - assert!(body["staleAfter"].as_str().unwrap() > body["generatedAt"].as_str().unwrap()); + .contains("required capability")); + assert_eq!( + body["inputs"]["requiredCapabilities"], + serde_json::json!(["gpu", "long-running-loop"]) + ); + assert_eq!(body["inputs"]["queuePressure"], "low"); + assert_eq!(body["inputs"]["travelState"], "hub_active"); + + let persisted = handle_request( + "GET", + &format!( + "/api/v1/scheduler/decisions/{}", + body["decisionId"].as_str().unwrap() + ), + temp_dir.path(), + None, + )?; + assert_eq!(persisted.status, 200); + assert_eq!(persisted.body, response.body); Ok(()) } #[test] - fn travel_profile_generation_reuses_stable_source_hub_identity() -> anyhow::Result<()> { + fn scheduler_decision_rejects_laptop_local_work_when_travel_battery_is_low( + ) -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + let request = serde_json::json!({ + "jobId": "job-local-notes", + "requiredCapabilities": ["shell"], + "taskWeight": "lightweight", + "travelState": "travel_local", + "nodes": [ + { + "nodeId": "laptop-travel", + "role": "laptop_local", + "available": true, + "capabilities": ["shell"], + "queuePressure": 0, + "batteryPercent": 9, + "powerSource": "battery" + } + ] + }); - let first = handle_request_with_body( + let response = handle_request_with_body( "POST", - "/api/v1/travel/profiles", + "/api/v1/scheduler/decisions", temp_dir.path(), None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + Some(&request.to_string()), )?; - let second = handle_request_with_body( + + assert_eq!(response.status, 409); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "no_scheduler_target"); + assert_eq!(body["error"]["details"]["travelState"], "travel_local"); + assert_eq!(body["error"]["details"]["batteryAware"], true); + Ok(()) + } + + #[test] + fn scheduler_failure_simulation_redispatches_when_compute_executor_goes_offline_mid_loop( + ) -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let request = serde_json::json!({ + "loopId": "loop-gpu", + "jobId": "job-gpu-loop", + "currentNodeId": "compute-primary", + "requiredCapabilities": ["gpu", "long-running-loop"], + "loopResumable": true, + "nodes": [ + { + "nodeId": "compute-primary", + "role": "compute_executor", + "available": false, + "capabilities": ["gpu", "long-running-loop"], + "queuePressure": 3, + "queuedJobIds": ["job-gpu-loop"] + }, + { + "nodeId": "compute-fallback", + "role": "compute_executor", + "available": true, + "capabilities": ["gpu", "long-running-loop"], + "queuePressure": 1, + "queuedJobIds": [] + } + ] + }); + + let response = handle_request_with_body( "POST", - "/api/v1/travel/profiles", + "/api/v1/scheduler/redispatch", temp_dir.path(), None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + Some(&request.to_string()), )?; - let first: serde_json::Value = serde_json::from_str(&first.body)?; - let second: serde_json::Value = serde_json::from_str(&second.body)?; - assert_eq!(first["sourceHub"]["hubId"], second["sourceHub"]["hubId"]); - assert_ne!(first["profileId"], second["profileId"]); + assert_eq!(response.status, 202); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["state"], "redispatched"); + assert_eq!(body["loopId"], "loop-gpu"); + assert_eq!(body["target"]["nodeId"], "compute-fallback"); + assert_eq!(body["preservedSubqueue"]["nodeId"], "compute-primary"); + assert_eq!( + body["preservedSubqueue"]["jobIds"], + serde_json::json!(["job-gpu-loop"]) + ); + assert!(body["reason"].as_str().unwrap().contains("offline")); + assert!(body["decisionId"].as_str().unwrap().starts_with("sched_")); Ok(()) } #[test] - fn travel_profile_generation_embeds_familiar_memory_context_and_readonly_artifact( + fn scheduler_failure_simulation_pauses_when_stationary_executor_goes_offline_without_alternate( ) -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let memory_dir = temp_dir.path().join("memory").join("sage"); - std::fs::create_dir_all(&memory_dir)?; - std::fs::write( - memory_dir.join("field-notes.md"), - "# Field notes\n\nSage remembers the travel-mode acceptance criteria.", - )?; + let request = serde_json::json!({ + "loopId": "loop-shell", + "jobId": "job-shell-loop", + "currentNodeId": "stationary-primary", + "requiredCapabilities": ["shell"], + "loopResumable": false, + "nodes": [ + { + "nodeId": "stationary-primary", + "role": "stationary_executor", + "available": false, + "capabilities": ["shell"], + "queuePressure": 2, + "queuedJobIds": ["job-shell-loop"] + } + ] + }); let response = handle_request_with_body( "POST", - "/api/v1/travel/profiles", + "/api/v1/scheduler/redispatch", temp_dir.path(), None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + Some(&request.to_string()), )?; - assert_eq!(response.status, 201); + assert_eq!(response.status, 202); let body: serde_json::Value = serde_json::from_str(&response.body)?; - let profile_id = body["profileId"].as_str().unwrap(); - let blob = body["profileBlob"].as_str().unwrap(); - let compressed = base64::engine::general_purpose::STANDARD.decode(blob)?; - let mut decoder = flate2::read::GzDecoder::new(&compressed[..]); - let mut decoded = String::new(); - std::io::Read::read_to_string(&mut decoder, &mut decoded)?; - let profile: serde_json::Value = serde_json::from_str(&decoded)?; - assert_eq!( - profile["payload"]["memoryContext"][0]["path"], - "sage/field-notes.md" - ); + assert_eq!(body["state"], "paused"); + assert_eq!(body["loopId"], "loop-shell"); + assert_eq!(body["target"]["role"], "paused"); + assert_eq!(body["nodeAvailability"][0]["nodeId"], "stationary-primary"); + assert_eq!(body["nodeAvailability"][0]["available"], false); + assert_eq!(body["preservedSubqueue"]["nodeId"], "stationary-primary"); assert_eq!( - profile["payload"]["memoryContext"][0]["excerpt"], - "Sage remembers the travel-mode acceptance criteria." - ); - - let artifact = temp_dir - .path() - .join("travel") - .join("profiles") - .join(format!("{profile_id}.json.gz")); - assert!(artifact.exists(), "missing {}", artifact.display()); - assert!( - artifact.metadata()?.permissions().readonly(), - "travel profile artifact should be read-only" + body["preservedSubqueue"]["jobIds"], + serde_json::json!(["job-shell-loop"]) ); Ok(()) } #[test] - fn travel_delta_upload_appends_results_without_overwriting_canonical_memory( - ) -> anyhow::Result<()> { + fn scheduler_failure_simulation_persists_loop_state_for_restart_recovery() -> anyhow::Result<()> + { let temp_dir = tempfile::tempdir()?; - let profile_response = handle_request_with_body( + let request = serde_json::json!({ + "loopId": "loop-persistent", + "jobId": "job-persistent-loop", + "currentNodeId": "compute-primary", + "requiredCapabilities": ["gpu"], + "loopResumable": true, + "nodes": [ + { + "nodeId": "compute-primary", + "role": "compute_executor", + "available": false, + "capabilities": ["gpu"], + "queuePressure": 4, + "queuedJobIds": ["job-persistent-loop", "job-followup"] + }, + { + "nodeId": "compute-fallback", + "role": "compute_executor", + "available": true, + "capabilities": ["gpu"], + "queuePressure": 0, + "queuedJobIds": [] + } + ] + }); + let redispatch = handle_request_with_body( "POST", - "/api/v1/travel/profiles", + "/api/v1/scheduler/redispatch", temp_dir.path(), None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + Some(&request.to_string()), )?; - let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; - let profile_id = profile["profileId"].as_str().unwrap(); - let source_hub_id = profile["sourceHub"]["hubId"].as_str().unwrap(); - let memory_revision = profile["sourceRevision"]["memoryRevision"] - .as_str() - .unwrap(); - let loop_revision = profile["sourceRevision"]["loopRevision"].as_str().unwrap(); - let delta_body = serde_json::json!({ - "profileId": profile_id, - "sourceHubId": source_hub_id, - "sourceRevision": { - "memoryRevision": memory_revision, - "loopRevision": loop_revision - }, - "clientId": "laptop-1", - "events": [{"id":"event-1","kind":"assistant","text":"offline result"}], - "artifacts": [{"id":"artifact-1","kind":"summary"}], - "proposedMemoryAdditions": [{"path":"MEMORY.md","text":"append this"}], - "canonicalMemoryOverwrite": {"path":"MEMORY.md","text":"replace everything"} - }); + assert_eq!(redispatch.status, 202); - let response = handle_request_with_body( - "POST", - "/api/v1/travel/deltas", + let recovered = handle_request( + "GET", + "/api/v1/scheduler/loops/loop-persistent", temp_dir.path(), None, - Some(&delta_body.to_string()), )?; - assert_eq!(response.status, 202); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert!(body["deltaId"].as_str().unwrap().starts_with("delta_")); - assert_eq!(body["state"], "hub_resumed"); - assert_eq!(body["acceptedEvents"], 1); - assert_eq!(body["acceptedArtifacts"], 1); - assert_eq!(body["memoryReviewState"], "queued"); - assert_eq!(body["canonicalMemoryOverwriteApplied"], false); - assert!(body["hubRevision"]["memoryRevision"] - .as_str() - .unwrap() - .starts_with("mem_")); + assert_eq!(recovered.status, 200); + let body: serde_json::Value = serde_json::from_str(&recovered.body)?; + assert_eq!(body["loopId"], "loop-persistent"); + assert_eq!(body["state"], "redispatched"); + assert_eq!(body["jobId"], "job-persistent-loop"); + assert!(body["decisionId"].as_str().unwrap().starts_with("sched_")); + assert_eq!(body["target"]["nodeId"], "compute-fallback"); + assert_eq!(body["preservedSubqueue"]["nodeId"], "compute-primary"); + assert_eq!( + body["preservedSubqueue"]["jobIds"], + serde_json::json!(["job-persistent-loop", "job-followup"]) + ); + assert_eq!(body["nodeAvailability"][0]["available"], false); Ok(()) } #[test] - fn travel_delta_upload_rejects_expired_profiles() -> anyhow::Result<()> { + fn routes_store_vacuum_request_to_repair_response() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let profile_response = handle_request_with_body( - "POST", - "/api/v1/travel/profiles", - temp_dir.path(), - None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), + let home = temp_dir.path(); + let conn = store::open_store(&store_path(home))?; + store::insert_session( + &conn, + &store::SessionRecord { + id: "session-1".into(), + project_root: "/repo".into(), + harness: "codex".into(), + title: "demo".into(), + status: "completed".into(), + exit_code: Some(0), + archived_at: Some("2026-01-01T00:00:00Z".into()), + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }, + )?; + store::insert_json_event( + &conn, + "session-1", + "output", + &json!({"text": "phoenix rises"}), + "2026-01-01T00:00:01Z", )?; - let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; - let profile_id = profile["profileId"].as_str().unwrap(); - let conn = store::open_store(&store_path(temp_dir.path()))?; conn.execute( - "UPDATE travel_profiles SET expires_at = '2020-01-01T00:00:00Z' WHERE id = ?1", - [profile_id], + "INSERT INTO events_fts(events_fts) VALUES('delete-all')", + [], )?; + assert!(store::search_events(&conn, "phoenix")?.is_empty()); drop(conn); + let response = handle_request("POST", "/api/v1/store/vacuum", home, None)?; + + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["ok"], true); + assert_eq!(body["eventIndexRebuilt"], true); + let conn = store::open_store(&store_path(home))?; + assert_eq!(store::search_events(&conn, "phoenix")?.len(), 1); + Ok(()) + } + + #[test] + fn rejects_unknown_api_version_prefixes() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + let response = handle_request("GET", "/api/v2/health", temp_dir.path(), None)?; + + assert_eq!(response.status, 404); + assert!(response.body.contains(r#""code":"invalid_request""#)); + Ok(()) + } + + #[test] + fn routes_control_capabilities_discovery_to_json() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + let response = handle_request("GET", "/api/v1/capabilities", temp_dir.path(), None)?; + + assert_eq!(response.status, 200); + assert!(response.body.contains(r#""id":"coven.sessions""#)); + assert!(response.body.contains(r#""id":"coven.travel""#)); + assert!(response.body.contains(r#""id":"coven.scheduler""#)); + assert!(response.body.contains(r#""id":"coven.control.actions""#)); + assert!(response.body.contains(r#""id":"desktop.automation""#)); + assert!(response.body.contains(r#""policy":"requiresApproval""#)); + Ok(()) + } + + #[test] + fn control_action_routes_safe_capability_refresh() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let body = json!({ + "action": "coven.capabilities.refresh", + "origin": "external-client", + "intentId": "intent-1" + }) + .to_string(); + let response = handle_request_with_body( "POST", - "/api/v1/travel/deltas", + "/api/v1/actions", temp_dir.path(), None, - Some( - &serde_json::json!({ - "profileId": profile_id, - "sourceHubId": profile["sourceHub"]["hubId"], - "clientId": "laptop-1", - "events": [{"id":"event-1","kind":"assistant","text":"offline result"}] - }) - .to_string(), - ), + Some(&body), )?; - assert_eq!(response.status, 409); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "travel_profile_expired"); + assert_eq!(response.status, 200); + assert!(response.body.contains(r#""accepted":true"#)); + assert!(response + .body + .contains(r#""action":"coven.capabilities.refresh""#)); + assert!(response.body.contains(r#""kind":"capabilities.refreshed""#)); + assert!(response.body.contains(r#""origin":"external-client""#)); + assert!(response.body.contains(r#""intentId":"intent-1""#)); Ok(()) } #[test] - fn travel_state_reports_stale_profile_before_handoff() -> anyhow::Result<()> { + fn capabilities_only_advertise_routable_control_actions() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let profile_response = handle_request_with_body( - "POST", - "/api/v1/travel/profiles", - temp_dir.path(), - None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), - )?; - let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; - let profile_id = profile["profileId"].as_str().unwrap(); - let conn = store::open_store(&store_path(temp_dir.path()))?; - conn.execute( - "UPDATE travel_profiles SET stale_after = '2020-01-01T00:00:00Z' WHERE id = ?1", - [profile_id], - )?; - drop(conn); - let response = handle_request( - "GET", - &format!("/api/v1/travel/state?clientId=laptop-1&profileId={profile_id}"), - temp_dir.path(), - None, - )?; + let response = handle_request("GET", "/api/v1/capabilities", temp_dir.path(), None)?; assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["state"], "travel_stale"); - assert_eq!(body["profileId"], profile_id); - assert_eq!(body["profileFreshness"], "stale"); - assert_eq!(body["hubReachable"], false); + assert!(response + .body + .contains(r#""actions":["coven.capabilities.refresh"]"#)); + assert!(!response.body.contains("coven.sessions.launch")); + assert!(!response.body.contains("desktop.window.focus")); Ok(()) } #[test] - fn travel_state_refuses_local_execution_for_expired_profile() -> anyhow::Result<()> { + fn routes_harness_capability_aggregate_to_json() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let profile_response = handle_request_with_body( - "POST", - "/api/v1/travel/profiles", - temp_dir.path(), - None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), - )?; - let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; - let profile_id = profile["profileId"].as_str().unwrap(); - let conn = store::open_store(&store_path(temp_dir.path()))?; - conn.execute( - "UPDATE travel_profiles SET expires_at = '2020-01-01T00:00:00Z' WHERE id = ?1", - [profile_id], - )?; - drop(conn); let response = handle_request( "GET", - &format!("/api/v1/travel/state?clientId=laptop-1&profileId={profile_id}"), + "/api/v1/capabilities/harnesses", temp_dir.path(), None, )?; assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["state"], "travel_stale"); - assert_eq!(body["profileFreshness"], "expired"); - assert_eq!(body["travelExecutionAllowed"], false); + assert!(response.body.contains(r#""harness_capabilities""#)); + assert!(response.body.contains(r#""coven_skills""#)); + assert!(response.body.contains(r#""scanned_at""#)); + for harness in ["codex", "claude", "copilot"] { + assert!( + response + .body + .contains(&format!(r#""harness_id":"{harness}""#)), + "aggregate missing manifest for `{harness}`: {}", + response.body + ); + } + // The bare path stays the control-plane catalog: no harness manifests. + let catalog = handle_request("GET", "/api/v1/capabilities", temp_dir.path(), None)?; + assert!(!catalog.body.contains(r#""harness_capabilities""#)); Ok(()) } #[test] - fn travel_delta_upload_appends_offline_events_to_canonical_event_log() -> anyhow::Result<()> { + fn harness_capability_aggregate_accepts_refresh_query() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let profile_response = handle_request_with_body( - "POST", - "/api/v1/travel/profiles", - temp_dir.path(), - None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), - )?; - let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; - let response = handle_request_with_body( - "POST", - "/api/v1/travel/deltas", - temp_dir.path(), - None, - Some( - &serde_json::json!({ - "profileId": profile["profileId"], - "sourceHubId": profile["sourceHub"]["hubId"], - "clientId": "laptop-1", - "events": [ - {"id":"local-event-1","kind":"assistant","text":"offline result"} - ] - }) - .to_string(), - ), - )?; - assert_eq!(response.status, 202); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - let session_id = body["reconciliationSessionId"].as_str().unwrap(); - let events = handle_request( + let response = handle_request( "GET", - &format!("/api/v1/events?sessionId={session_id}"), + "/api/v1/capabilities/harnesses?refresh=1", temp_dir.path(), None, )?; - assert_eq!(events.status, 200); - let events_body: serde_json::Value = serde_json::from_str(&events.body)?; - assert_eq!(events_body["events"][0]["kind"], "travel.offline_event"); - assert!(events_body["events"][0]["payload_json"] - .as_str() - .unwrap() - .contains("offline result")); + + assert_eq!(response.status, 200); + assert!(response.body.contains(r#""harness_capabilities""#)); Ok(()) } #[test] - fn travel_state_exposes_handoff_states_for_clients() -> anyhow::Result<()> { + fn routes_single_harness_capability_manifest() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let initial = handle_request( - "GET", - "/api/v1/travel/state?clientId=laptop-1", - temp_dir.path(), - None, - )?; + let response = handle_request("GET", "/api/v1/capabilities/codex", temp_dir.path(), None)?; - assert_eq!(initial.status, 200); - let body: serde_json::Value = serde_json::from_str(&initial.body)?; - assert_eq!(body["state"], "hub_active"); - assert_eq!(body["hubReachable"], true); - assert_eq!(body["pendingDeltaBytes"], 0); + assert_eq!(response.status, 200); + assert!(response.body.contains(r#""harness_id":"codex""#)); + assert!(response.body.contains(r#""global_instructions""#)); + Ok(()) + } - let profile_response = handle_request_with_body( - "POST", - "/api/v1/travel/profiles", - temp_dir.path(), - None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), - )?; - let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; - let pending_delta = serde_json::json!({ - "profileId": profile["profileId"], - "sourceHubId": profile["sourceHub"]["hubId"], - "sourceRevision": profile["sourceRevision"], - "clientId": "laptop-1", - "events": [{"id":"event-1","kind":"assistant","text":"offline result"}] - }); - let _ = handle_request_with_body( - "POST", - "/api/v1/travel/deltas?defer=1", - temp_dir.path(), - None, - Some(&pending_delta.to_string()), - )?; + #[test] + fn unknown_harness_capability_manifest_fails_closed_with_structured_error() -> anyhow::Result<()> + { + let temp_dir = tempfile::tempdir()?; - let pending = handle_request( - "GET", - "/api/v1/travel/state?clientId=laptop-1", - temp_dir.path(), - None, - )?; + let response = + handle_request("GET", "/api/v1/capabilities/warlock", temp_dir.path(), None)?; - assert_eq!(pending.status, 200); - let body: serde_json::Value = serde_json::from_str(&pending.body)?; - assert_eq!(body["state"], "handoff_pending"); - assert_eq!(body["profileId"], profile["profileId"]); - assert!(body["pendingDeltaBytes"].as_i64().unwrap() > 0); - assert_eq!(body["profileFreshness"], "fresh"); + assert_eq!(response.status, 404); + assert!(response.body.contains(r#""code":"harness_not_found""#)); + assert!(response.body.contains(r#""harnessId":"warlock""#)); Ok(()) } #[test] - fn travel_failure_simulation_reconnect_walks_handoff_sync_and_resume_states( - ) -> anyhow::Result<()> { + fn malformed_control_actions_fail_closed_with_structured_json() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let profile_response = handle_request_with_body( - "POST", - "/api/v1/travel/profiles", - temp_dir.path(), - None, - Some(r#"{"familiarId":"sage","workspaceId":"workspace-1"}"#), - )?; - let profile: serde_json::Value = serde_json::from_str(&profile_response.body)?; - let profile_id = profile["profileId"].as_str().unwrap(); - - let local = handle_request( - "GET", - &format!("/api/v1/travel/state?clientId=laptop-1&profileId={profile_id}"), - temp_dir.path(), - None, - )?; - let local_body: serde_json::Value = serde_json::from_str(&local.body)?; - assert_eq!(local_body["state"], "travel_local"); - assert_eq!(local_body["hubReachable"], false); - let delta = serde_json::json!({ - "profileId": profile["profileId"], - "sourceHubId": profile["sourceHub"]["hubId"], - "sourceRevision": profile["sourceRevision"], - "clientId": "laptop-1", - "events": [{"id":"event-1","kind":"assistant","text":"offline result"}] - }); - let handoff = handle_request_with_body( + let malformed = handle_request_with_body( "POST", - "/api/v1/travel/deltas?state=handoff_pending", + "/api/v1/actions", temp_dir.path(), None, - Some(&delta.to_string()), + Some("{not-json"), )?; - assert_eq!(handoff.status, 202); - let handoff_body: serde_json::Value = serde_json::from_str(&handoff.body)?; - assert_eq!(handoff_body["state"], "handoff_pending"); - - let syncing = handle_request_with_body( + let missing = handle_request_with_body( "POST", - "/api/v1/travel/deltas?state=syncing_delta", + "/api/v1/actions", temp_dir.path(), None, - Some(&delta.to_string()), + Some(r#"{"origin":"external-client"}"#), )?; - assert_eq!(syncing.status, 202); - let syncing_body: serde_json::Value = serde_json::from_str(&syncing.body)?; - assert_eq!(syncing_body["state"], "syncing_delta"); - - let syncing_state = handle_request( - "GET", - "/api/v1/travel/state?clientId=laptop-1", + let empty = handle_request_with_body( + "POST", + "/api/v1/actions", temp_dir.path(), None, + Some(r#"{"action":" "}"#), )?; - let syncing_state_body: serde_json::Value = serde_json::from_str(&syncing_state.body)?; - assert_eq!(syncing_state_body["state"], "syncing_delta"); - assert_eq!(syncing_state_body["hubReachable"], true); - - let resumed = handle_request_with_body( + let non_object = handle_request_with_body( "POST", - "/api/v1/travel/deltas", + "/api/v1/actions", temp_dir.path(), None, - Some(&delta.to_string()), + Some(r#"["not","an","object"]"#), )?; - assert_eq!(resumed.status, 202); - let resumed_body: serde_json::Value = serde_json::from_str(&resumed.body)?; - assert_eq!(resumed_body["state"], "hub_resumed"); + + assert_eq!(malformed.status, 400); + assert_eq!(missing.status, 400); + assert_eq!(empty.status, 400); + assert_eq!(non_object.status, 400); + assert!(malformed.body.contains(r#""accepted":false"#)); + assert!(malformed.body.contains(r#""action":"(unknown)""#)); + assert!(missing.body.contains("request body requires string field")); + assert!(empty.body.contains("request body requires string field")); + assert!(non_object + .body + .contains("request body must be a JSON object")); Ok(()) } #[test] - fn scheduler_decision_selects_available_executor_by_capability_and_queue_pressure( - ) -> anyhow::Result<()> { + fn control_action_blocks_unknown_actions_before_adapters_run() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let request = serde_json::json!({ - "jobId": "job-gpu-loop", - "requiredCapabilities": ["gpu", "long-running-loop"], - "taskWeight": "heavyweight", - "travelState": "hub_active", - "nodes": [ - { - "nodeId": "node-stationary", - "role": "stationary_executor", - "available": true, - "capabilities": ["shell"], - "queuePressure": 0 - }, - { - "nodeId": "node-compute-busy", - "role": "compute_executor", - "available": true, - "capabilities": ["gpu", "long-running-loop"], - "queuePressure": 8 - }, - { - "nodeId": "node-compute-idle", - "role": "compute_executor", - "available": true, - "capabilities": ["gpu", "long-running-loop"], - "queuePressure": 1 - } - ] - }); + let body = json!({ + "action": "desktop.deleteEverything", + "origin": "external-client" + }) + .to_string(); let response = handle_request_with_body( "POST", - "/api/v1/scheduler/decisions", + "/api/v1/actions", temp_dir.path(), None, - Some(&request.to_string()), + Some(&body), )?; - assert_eq!(response.status, 201); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert!(body["decisionId"].as_str().unwrap().starts_with("sched_")); - assert_eq!(body["jobId"], "job-gpu-loop"); - assert_eq!(body["target"]["role"], "compute_executor"); - assert_eq!(body["target"]["nodeId"], "node-compute-idle"); - assert!(body["reason"] - .as_str() - .unwrap() - .contains("required capability")); - assert_eq!( - body["inputs"]["requiredCapabilities"], - serde_json::json!(["gpu", "long-running-loop"]) - ); - assert_eq!(body["inputs"]["queuePressure"], "low"); - assert_eq!(body["inputs"]["travelState"], "hub_active"); + assert_eq!(response.status, 400); + assert!(response.body.contains(r#""accepted":false"#)); + assert!(response.body.contains("unknown action")); + Ok(()) + } - let persisted = handle_request( - "GET", - &format!( - "/api/v1/scheduler/decisions/{}", - body["decisionId"].as_str().unwrap() - ), - temp_dir.path(), - None, - )?; - assert_eq!(persisted.status, 200); - assert_eq!(persisted.body, response.body); + #[test] + fn routes_sessions_list_and_detail_requests_to_json() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let conn = crate::store::open_store(&temp_dir.path().join("coven.sqlite3"))?; + let session = crate::store::SessionRecord { + id: "session-1".to_string(), + project_root: "/repo".to_string(), + harness: "codex".to_string(), + title: "hello from coven".to_string(), + status: "created".to_string(), + exit_code: None, + archived_at: None, + created_at: "2026-04-27T10:00:00Z".to_string(), + updated_at: "2026-04-27T10:00:00Z".to_string(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }; + crate::store::insert_session(&conn, &session)?; + + let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; + let detail = handle_request("GET", "/sessions/session-1", temp_dir.path(), None)?; + + assert_eq!(list.status, 200); + assert!(list.body.contains(r#""id":"session-1""#)); + assert_eq!(detail.status, 200); + assert!(detail.body.contains(r#""title":"hello from coven""#)); Ok(()) } #[test] - fn scheduler_decision_rejects_laptop_local_work_when_travel_battery_is_low( - ) -> anyhow::Result<()> { + fn returns_not_found_for_unknown_session() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let request = serde_json::json!({ - "jobId": "job-local-notes", - "requiredCapabilities": ["shell"], - "taskWeight": "lightweight", - "travelState": "travel_local", - "nodes": [ - { - "nodeId": "laptop-travel", - "role": "laptop_local", - "available": true, - "capabilities": ["shell"], - "queuePressure": 0, - "batteryPercent": 9, - "powerSource": "battery" - } - ] - }); + let response = handle_request("GET", "/sessions/missing", temp_dir.path(), None)?; - let response = handle_request_with_body( + assert_eq!(response.status, 404); + assert!(response.body.contains(r#""code":"session_not_found""#)); + Ok(()) + } + + #[test] + fn launch_request_invokes_runtime_and_persists_running_session() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let project_root = temp_dir.path().join("repo"); + let cwd = project_root.join("app"); + std::fs::create_dir_all(&cwd)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "cwd": cwd, + "harness": "codex", + "model": "openai/gpt-5.6-sol", + "prompt": "hello coven", + "title": "Demo" + }) + .to_string(); + + let response = handle_request_with_runtime( "POST", - "/api/v1/scheduler/decisions", + "/sessions", temp_dir.path(), None, - Some(&request.to_string()), + Some(&body), + &runtime, )?; + let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; - assert_eq!(response.status, 409); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "no_scheduler_target"); - assert_eq!(body["error"]["details"]["travelState"], "travel_local"); - assert_eq!(body["error"]["details"]["batteryAware"], true); + assert_eq!(response.status, 201); + assert!(response.body.contains(r#""status":"running""#)); + assert_eq!(runtime.launches.borrow().len(), 1); + assert_eq!(runtime.launches.borrow()[0].harness, "codex"); + assert_eq!( + runtime.launches.borrow()[0].model.as_deref(), + Some("openai/gpt-5.6-sol") + ); + assert_eq!( + runtime.launches.borrow()[0].launch_mode, + HarnessLaunchMode::Interactive + ); + assert_eq!(runtime.launches.borrow()[0].prompt, "hello coven"); + assert_eq!( + runtime.launches.borrow()[0].project_root, + project::canonical_project_root(&project_root)?.to_string_lossy() + ); + assert_eq!( + runtime.launches.borrow()[0].cwd, + project::resolve_inside_root( + &project::canonical_project_root(&project_root)?, + Some(&cwd) + )? + .to_string_lossy() + ); + assert!(list.body.contains(r#""title":"Demo""#)); Ok(()) } #[test] - fn scheduler_failure_simulation_redispatches_when_compute_executor_goes_offline_mid_loop( - ) -> anyhow::Result<()> { + fn launch_request_accepts_non_interactive_mode_for_plain_chat() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let request = serde_json::json!({ - "loopId": "loop-gpu", - "jobId": "job-gpu-loop", - "currentNodeId": "compute-primary", - "requiredCapabilities": ["gpu", "long-running-loop"], - "loopResumable": true, - "nodes": [ - { - "nodeId": "compute-primary", - "role": "compute_executor", - "available": false, - "capabilities": ["gpu", "long-running-loop"], - "queuePressure": 3, - "queuedJobIds": ["job-gpu-loop"] - }, - { - "nodeId": "compute-fallback", - "role": "compute_executor", - "available": true, - "capabilities": ["gpu", "long-running-loop"], - "queuePressure": 1, - "queuedJobIds": [] - } - ] - }); + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "codex", + "launchMode": "nonInteractive", + "prompt": "hello coven" + }) + .to_string(); - let response = handle_request_with_body( + let response = handle_request_with_runtime( "POST", - "/api/v1/scheduler/redispatch", + "/sessions", temp_dir.path(), None, - Some(&request.to_string()), + Some(&body), + &runtime, )?; - assert_eq!(response.status, 202); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["state"], "redispatched"); - assert_eq!(body["loopId"], "loop-gpu"); - assert_eq!(body["target"]["nodeId"], "compute-fallback"); - assert_eq!(body["preservedSubqueue"]["nodeId"], "compute-primary"); + assert_eq!(response.status, 201); assert_eq!( - body["preservedSubqueue"]["jobIds"], - serde_json::json!(["job-gpu-loop"]) + runtime.launches.borrow()[0].launch_mode, + HarnessLaunchMode::NonInteractive ); - assert!(body["reason"].as_str().unwrap().contains("offline")); - assert!(body["decisionId"].as_str().unwrap().starts_with("sched_")); + assert!(runtime.launches.borrow()[0].model.is_none()); Ok(()) } #[test] - fn scheduler_failure_simulation_pauses_when_stationary_executor_goes_offline_without_alternate( - ) -> anyhow::Result<()> { + fn launch_request_threads_conversation_hint_through_to_runtime() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let request = serde_json::json!({ - "loopId": "loop-shell", - "jobId": "job-shell-loop", - "currentNodeId": "stationary-primary", - "requiredCapabilities": ["shell"], - "loopResumable": false, - "nodes": [ - { - "nodeId": "stationary-primary", - "role": "stationary_executor", - "available": false, - "capabilities": ["shell"], - "queuePressure": 2, - "queuedJobIds": ["job-shell-loop"] - } - ] - }); + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "hello", + "conversation": {"mode": "init", "id": "abc-123"} + }) + .to_string(); - let response = handle_request_with_body( + let response = handle_request_with_runtime( "POST", - "/api/v1/scheduler/redispatch", + "/sessions", temp_dir.path(), None, - Some(&request.to_string()), + Some(&body), + &runtime, )?; - assert_eq!(response.status, 202); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["state"], "paused"); - assert_eq!(body["loopId"], "loop-shell"); - assert_eq!(body["target"]["role"], "paused"); - assert_eq!(body["nodeAvailability"][0]["nodeId"], "stationary-primary"); - assert_eq!(body["nodeAvailability"][0]["available"], false); - assert_eq!(body["preservedSubqueue"]["nodeId"], "stationary-primary"); + assert_eq!(response.status, 201); assert_eq!( - body["preservedSubqueue"]["jobIds"], - serde_json::json!(["job-shell-loop"]) + runtime.launches.borrow()[0].conversation, + Some(ConversationHint::Init { + id: "abc-123".to_string() + }) ); Ok(()) } #[test] - fn scheduler_failure_simulation_persists_loop_state_for_restart_recovery() -> anyhow::Result<()> - { + fn launch_request_accepts_resume_conversation_hint() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let request = serde_json::json!({ - "loopId": "loop-persistent", - "jobId": "job-persistent-loop", - "currentNodeId": "compute-primary", - "requiredCapabilities": ["gpu"], - "loopResumable": true, - "nodes": [ - { - "nodeId": "compute-primary", - "role": "compute_executor", - "available": false, - "capabilities": ["gpu"], - "queuePressure": 4, - "queuedJobIds": ["job-persistent-loop", "job-followup"] - }, - { - "nodeId": "compute-fallback", - "role": "compute_executor", - "available": true, - "capabilities": ["gpu"], - "queuePressure": 0, - "queuedJobIds": [] - } - ] - }); - let redispatch = handle_request_with_body( + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "follow up", + "conversation": {"mode": "resume", "id": "abc-123"} + }) + .to_string(); + + handle_request_with_runtime( "POST", - "/api/v1/scheduler/redispatch", + "/sessions", temp_dir.path(), None, - Some(&request.to_string()), + Some(&body), + &runtime, )?; - assert_eq!(redispatch.status, 202); - let recovered = handle_request( - "GET", - "/api/v1/scheduler/loops/loop-persistent", + assert_eq!( + runtime.launches.borrow()[0].conversation, + Some(ConversationHint::Resume { + id: "abc-123".to_string() + }) + ); + Ok(()) + } + + #[test] + fn launch_request_persists_conversation_id_on_the_session_row() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "hi", + "conversation": {"mode": "init", "id": "abc-123"}, + "conversationId": "abc-123" + }) + .to_string(); + + handle_request_with_runtime( + "POST", + "/sessions", temp_dir.path(), None, + Some(&body), + &runtime, )?; - assert_eq!(recovered.status, 200); - let body: serde_json::Value = serde_json::from_str(&recovered.body)?; - assert_eq!(body["loopId"], "loop-persistent"); - assert_eq!(body["state"], "redispatched"); - assert_eq!(body["jobId"], "job-persistent-loop"); - assert!(body["decisionId"].as_str().unwrap().starts_with("sched_")); - assert_eq!(body["target"]["nodeId"], "compute-fallback"); - assert_eq!(body["preservedSubqueue"]["nodeId"], "compute-primary"); assert_eq!( - body["preservedSubqueue"]["jobIds"], - serde_json::json!(["job-persistent-loop", "job-followup"]) + runtime.launches.borrow()[0].conversation_id.as_deref(), + Some("abc-123") + ); + + // And it round-trips through the session list payload too. + let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; + assert!( + list.body.contains(r#""conversation_id":"abc-123""#), + "list response should expose conversation_id, got: {}", + list.body ); - assert_eq!(body["nodeAvailability"][0]["available"], false); Ok(()) } #[test] - fn routes_store_vacuum_request_to_repair_response() -> anyhow::Result<()> { + fn launch_request_treats_missing_conversation_id_as_null() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let home = temp_dir.path(); - let conn = store::open_store(&store_path(home))?; - store::insert_session( - &conn, - &store::SessionRecord { - id: "session-1".into(), - project_root: "/repo".into(), - harness: "codex".into(), - title: "demo".into(), - status: "completed".into(), - exit_code: Some(0), - archived_at: Some("2026-01-01T00:00:00Z".into()), - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-01-01T00:00:00Z".into(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }, - )?; - store::insert_json_event( - &conn, - "session-1", - "output", - &json!({"text": "phoenix rises"}), - "2026-01-01T00:00:01Z", - )?; - conn.execute( - "INSERT INTO events_fts(events_fts) VALUES('delete-all')", - [], - )?; - assert!(store::search_events(&conn, "phoenix")?.is_empty()); - drop(conn); + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "hi" + }) + .to_string(); - let response = handle_request("POST", "/api/v1/store/vacuum", home, None)?; + handle_request_with_runtime( + "POST", + "/sessions", + temp_dir.path(), + None, + Some(&body), + &runtime, + )?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["ok"], true); - assert_eq!(body["eventIndexRebuilt"], true); - let conn = store::open_store(&store_path(home))?; - assert_eq!(store::search_events(&conn, "phoenix")?.len(), 1); + assert!(runtime.launches.borrow()[0].conversation_id.is_none()); Ok(()) } - #[test] - fn rejects_unknown_api_version_prefixes() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; + #[test] + fn launch_request_persists_familiar_id_on_the_session_row() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + seed_familiars_toml(temp_dir.path())?; + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "hi", + "familiarId": "sage" + }) + .to_string(); + + handle_request_with_runtime( + "POST", + "/sessions", + temp_dir.path(), + None, + Some(&body), + &runtime, + )?; - let response = handle_request("GET", "/api/v2/health", temp_dir.path(), None)?; + assert_eq!( + runtime.launches.borrow()[0].familiar_id.as_deref(), + Some("sage") + ); - assert_eq!(response.status, 404); - assert!(response.body.contains(r#""code":"invalid_request""#)); + // And it round-trips through the session list payload too. + let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; + assert!( + list.body.contains(r#""familiar_id":"sage""#), + "list response should expose familiar_id, got: {}", + list.body + ); Ok(()) } #[test] - fn routes_control_capabilities_discovery_to_json() -> anyhow::Result<()> { + fn launch_request_rejects_unknown_familiar_id_without_inserting_session() -> anyhow::Result<()> + { let temp_dir = tempfile::tempdir()?; + seed_familiars_toml(temp_dir.path())?; + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "hi", + "familiarId": "missing" + }) + .to_string(); - let response = handle_request("GET", "/api/v1/capabilities", temp_dir.path(), None)?; + let response = handle_request_with_runtime( + "POST", + "/sessions", + temp_dir.path(), + None, + Some(&body), + &runtime, + )?; - assert_eq!(response.status, 200); - assert!(response.body.contains(r#""id":"coven.sessions""#)); - assert!(response.body.contains(r#""id":"coven.travel""#)); - assert!(response.body.contains(r#""id":"coven.scheduler""#)); - assert!(response.body.contains(r#""id":"coven.control.actions""#)); - assert!(response.body.contains(r#""id":"desktop.automation""#)); - assert!(response.body.contains(r#""policy":"requiresApproval""#)); + assert_eq!(response.status, 400); + assert!( + response.body.contains("unknown_familiar"), + "expected unknown familiar error, got: {}", + response.body + ); + assert!( + runtime.launches.borrow().is_empty(), + "unknown familiar must not launch a runtime" + ); + let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; + assert!( + list.body == "[]", + "unknown familiar must not insert a session row, got: {}", + list.body + ); Ok(()) } #[test] - fn control_action_routes_safe_capability_refresh() -> anyhow::Result<()> { + fn launch_request_reports_familiar_config_errors_without_launching() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + std::fs::write(temp_dir.path().join("familiars.toml"), "[[familiar]\n")?; + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); let body = json!({ - "action": "coven.capabilities.refresh", - "origin": "external-client", - "intentId": "intent-1" + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "hi", + "familiarId": "sage" }) .to_string(); - let response = handle_request_with_body( + let response = handle_request_with_runtime( "POST", - "/api/v1/actions", + "/sessions", temp_dir.path(), None, Some(&body), + &runtime, )?; - assert_eq!(response.status, 200); - assert!(response.body.contains(r#""accepted":true"#)); - assert!(response - .body - .contains(r#""action":"coven.capabilities.refresh""#)); - assert!(response.body.contains(r#""kind":"capabilities.refreshed""#)); - assert!(response.body.contains(r#""origin":"external-client""#)); - assert!(response.body.contains(r#""intentId":"intent-1""#)); + assert_eq!(response.status, 500); + assert!( + response.body.contains("familiar_lookup_failed"), + "expected familiar config error, got: {}", + response.body + ); + assert!( + runtime.launches.borrow().is_empty(), + "malformed familiar config must not launch a runtime" + ); Ok(()) } #[test] - fn capabilities_only_advertise_routable_control_actions() -> anyhow::Result<()> { + fn conversation_id_rejects_shell_metacharacters() { + // Ids carrying whitespace or shell metacharacters must be rejected so they + // can never reach the harness CLI's `--session-id`/`--resume`/`resume` argv, + // where on Windows a `.cmd` shim would re-parse cmd.exe metacharacters. + for bad in [ + "not-a-uuid & calc.exe", + "a|b", + "a b", + "$(whoami)", + "a\"b", + "a^b", + ] { + let payload = json!({"conversation": {"mode": "resume", "id": bad}}); + assert!( + conversation_from_payload(&payload).is_err(), + "expected {bad:?} to be rejected" + ); + } + // UUIDs and opaque slugs are accepted. + for good in [ + "11111111-2222-3333-4444-555555555555", + "abc-123", + "sess_1.2", + ] { + let payload = json!({"conversation": {"mode": "init", "id": good}}); + assert!( + conversation_from_payload(&payload) + .expect("shell-safe id should parse") + .is_some(), + "expected {good:?} to be accepted" + ); + } + } + + #[test] + fn launch_request_with_malformed_conversation_mode_returns_400_not_daemon_crash( + ) -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "harness": "claude", + "launchMode": "nonInteractive", + "prompt": "hi", + "conversation": {"mode": "forge", "id": "abc"} + }) + .to_string(); - let response = handle_request("GET", "/api/v1/capabilities", temp_dir.path(), None)?; + let response = handle_request_with_runtime( + "POST", + "/sessions", + temp_dir.path(), + None, + Some(&body), + &runtime, + )?; - assert_eq!(response.status, 200); - assert!(response - .body - .contains(r#""actions":["coven.capabilities.refresh"]"#)); - assert!(!response.body.contains("coven.sessions.launch")); - assert!(!response.body.contains("desktop.window.focus")); + // Must be a structured 400 — bubbling the error up to the + // daemon's accept loop would take the daemon down. + assert_eq!(response.status, 400); + assert!( + response.body.contains("conversation.mode"), + "expected body to mention conversation.mode, got: {}", + response.body + ); + assert!( + response.body.contains("invalid_request"), + "expected structured `invalid_request` code, got: {}", + response.body + ); + assert!(runtime.launches.borrow().is_empty()); Ok(()) } #[test] - fn routes_harness_capability_aggregate_to_json() -> anyhow::Result<()> { + fn launch_request_runtime_failure_returns_500_and_marks_session_failed() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = FailingLaunchRuntime; + let body = json!({ + "projectRoot": project_root, + "harness": "codex", + "prompt": "hello" + }) + .to_string(); - let response = handle_request( - "GET", - "/api/v1/capabilities/harnesses", + let response = handle_request_with_runtime( + "POST", + "/sessions", temp_dir.path(), None, + Some(&body), + &runtime, )?; + let sessions = handle_request("GET", "/sessions", temp_dir.path(), None)?; - assert_eq!(response.status, 200); - assert!(response.body.contains(r#""harness_capabilities""#)); - assert!(response.body.contains(r#""coven_skills""#)); - assert!(response.body.contains(r#""scanned_at""#)); - for harness in ["codex", "claude", "copilot"] { - assert!( - response - .body - .contains(&format!(r#""harness_id":"{harness}""#)), - "aggregate missing manifest for `{harness}`: {}", - response.body - ); - } - // The bare path stays the control-plane catalog: no harness manifests. - let catalog = handle_request("GET", "/api/v1/capabilities", temp_dir.path(), None)?; - assert!(!catalog.body.contains(r#""harness_capabilities""#)); + // Must be a structured response — propagating Err would crash + // the daemon's accept loop. + assert_eq!(response.status, 500); + assert!( + response.body.contains("launch_failed"), + "expected structured `launch_failed` code, got: {}", + response.body + ); + assert!( + response.body.contains("launch failed"), + "expected runtime error message in the body, got: {}", + response.body + ); + assert!(sessions.body.contains(r#""status":"failed""#)); Ok(()) } #[test] - fn harness_capability_aggregate_accepts_refresh_query() -> anyhow::Result<()> { + fn launch_request_rejects_cwd_outside_project_root() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + let project_root = temp_dir.path().join("repo"); + let outside = temp_dir.path().join("outside"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&outside)?; + let runtime = RecordingRuntime::default(); + let body = json!({ + "projectRoot": project_root, + "cwd": outside, + "harness": "codex", + "prompt": "hello" + }) + .to_string(); - let response = handle_request( - "GET", - "/api/v1/capabilities/harnesses?refresh=1", + let response = handle_request_with_runtime( + "POST", + "/sessions", temp_dir.path(), None, + Some(&body), + &runtime, )?; - assert_eq!(response.status, 200); - assert!(response.body.contains(r#""harness_capabilities""#)); + assert_eq!(response.status, 400); + assert!( + response.body.contains("outside the Coven project root"), + "unexpected body: {}", + response.body + ); + assert!(runtime.launches.borrow().is_empty()); Ok(()) } #[test] - fn routes_single_harness_capability_manifest() -> anyhow::Result<()> { + fn launch_request_rejects_missing_required_fields() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + let runtime = RecordingRuntime::default(); - let response = handle_request("GET", "/api/v1/capabilities/codex", temp_dir.path(), None)?; + let response = handle_request_with_runtime( + "POST", + "/sessions", + temp_dir.path(), + None, + Some(r#"{"harness":"codex"}"#), + &runtime, + )?; - assert_eq!(response.status, 200); - assert!(response.body.contains(r#""harness_id":"codex""#)); - assert!(response.body.contains(r#""global_instructions""#)); + assert_eq!(response.status, 400); + assert!(response.body.contains("projectRoot")); + assert!(response.body.contains("invalid_request")); + assert!(runtime.launches.borrow().is_empty()); Ok(()) } #[test] - fn unknown_harness_capability_manifest_fails_closed_with_structured_error() -> anyhow::Result<()> - { + fn input_request_records_session_event() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + insert_test_session(temp_dir.path(), "session-1")?; - let response = - handle_request("GET", "/api/v1/capabilities/warlock", temp_dir.path(), None)?; + let response = handle_request_with_body( + "POST", + "/sessions/session-1/input", + temp_dir.path(), + None, + Some(r#"{"data":"hello coven"}"#), + )?; + let events = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; - assert_eq!(response.status, 404); - assert!(response.body.contains(r#""code":"harness_not_found""#)); - assert!(response.body.contains(r#""harnessId":"warlock""#)); + assert_eq!(response.status, 202); + assert!(response.body.contains(r#""accepted":true"#)); + assert_eq!(events.status, 200); + assert!(events.body.contains(r#""kind":"input""#)); + assert!(events.body.contains("hello coven")); Ok(()) } #[test] - fn malformed_control_actions_fail_closed_with_structured_json() -> anyhow::Result<()> { + fn input_request_invokes_live_runtime_hook() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; + insert_test_session(temp_dir.path(), "session-1")?; + let runtime = RecordingRuntime::default(); - let malformed = handle_request_with_body( - "POST", - "/api/v1/actions", - temp_dir.path(), - None, - Some("{not-json"), - )?; - let missing = handle_request_with_body( - "POST", - "/api/v1/actions", - temp_dir.path(), - None, - Some(r#"{"origin":"external-client"}"#), - )?; - let empty = handle_request_with_body( - "POST", - "/api/v1/actions", - temp_dir.path(), - None, - Some(r#"{"action":" "}"#), - )?; - let non_object = handle_request_with_body( + let response = handle_request_with_runtime( "POST", - "/api/v1/actions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(r#"["not","an","object"]"#), + Some(r#"{"data":"hello coven"}"#), + &runtime, )?; - assert_eq!(malformed.status, 400); - assert_eq!(missing.status, 400); - assert_eq!(empty.status, 400); - assert_eq!(non_object.status, 400); - assert!(malformed.body.contains(r#""accepted":false"#)); - assert!(malformed.body.contains(r#""action":"(unknown)""#)); - assert!(missing.body.contains("request body requires string field")); - assert!(empty.body.contains("request body requires string field")); - assert!(non_object - .body - .contains("request body must be a JSON object")); + assert_eq!(response.status, 202); + assert_eq!( + runtime.inputs.borrow().as_slice(), + &["session-1:hello coven"] + ); Ok(()) } #[test] - fn control_action_blocks_unknown_actions_before_adapters_run() -> anyhow::Result<()> { + fn launch_request_with_unknown_harness_returns_400_upfront_no_session_row() -> anyhow::Result<()> + { let temp_dir = tempfile::tempdir()?; + let project_root = temp_dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let runtime = RecordingRuntime::default(); let body = json!({ - "action": "desktop.deleteEverything", - "origin": "external-client" + "projectRoot": project_root, + "harness": "hermes", + "launchMode": "nonInteractive", + "prompt": "hello" }) .to_string(); - let response = handle_request_with_body( + let response = handle_request_with_runtime( "POST", - "/api/v1/actions", + "/sessions", temp_dir.path(), None, Some(&body), + &runtime, )?; assert_eq!(response.status, 400); - assert!(response.body.contains(r#""accepted":false"#)); - assert!(response.body.contains("unknown action")); + assert!( + response.body.contains("unsupported harness `hermes`"), + "expected unsupported-harness validation message, got: {}", + response.body + ); + assert!( + response + .body + .contains(crate::harness::EXTERNAL_ADAPTER_MANIFEST_ENV), + "expected external adapter manifest guidance, got: {}", + response.body + ); + assert!( + runtime.launches.borrow().is_empty(), + "runtime must not be invoked for an unsupported harness" + ); + // And no session row should have been inserted. + let sessions = handle_request("GET", "/sessions", temp_dir.path(), None)?; + assert!( + sessions.body.contains("[]"), + "no session row should exist after a 400-rejected launch, got: {}", + sessions.body + ); Ok(()) } #[test] - fn routes_sessions_list_and_detail_requests_to_json() -> anyhow::Result<()> { + fn input_request_with_missing_data_field_returns_400_not_500() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let conn = crate::store::open_store(&temp_dir.path().join("coven.sqlite3"))?; - let session = crate::store::SessionRecord { - id: "session-1".to_string(), - project_root: "/repo".to_string(), - harness: "codex".to_string(), - title: "hello from coven".to_string(), - status: "created".to_string(), - exit_code: None, - archived_at: None, - created_at: "2026-04-27T10:00:00Z".to_string(), - updated_at: "2026-04-27T10:00:00Z".to_string(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }; - crate::store::insert_session(&conn, &session)?; - - let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; - let detail = handle_request("GET", "/sessions/session-1", temp_dir.path(), None)?; - - assert_eq!(list.status, 200); - assert!(list.body.contains(r#""id":"session-1""#)); - assert_eq!(detail.status, 200); - assert!(detail.body.contains(r#""title":"hello from coven""#)); - Ok(()) - } + insert_test_session(temp_dir.path(), "session-1")?; - #[test] - fn returns_not_found_for_unknown_session() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - let response = handle_request("GET", "/sessions/missing", temp_dir.path(), None)?; + let response = handle_request_with_body( + "POST", + "/sessions/session-1/input", + temp_dir.path(), + None, + Some(r#"{"foo":"bar"}"#), + )?; - assert_eq!(response.status, 404); - assert!(response.body.contains(r#""code":"session_not_found""#)); + assert_eq!(response.status, 400); + assert!( + response.body.contains("invalid_request"), + "missing `data` is a client error, expected 400 invalid_request, got: {}", + response.body + ); + assert!( + response.body.contains("`data`"), + "expected the message to name the missing field, got: {}", + response.body + ); Ok(()) } #[test] - fn launch_request_invokes_runtime_and_persists_running_session() -> anyhow::Result<()> { + fn input_request_with_non_string_data_field_returns_400_not_500() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - let cwd = project_root.join("app"); - std::fs::create_dir_all(&cwd)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "cwd": cwd, - "harness": "codex", - "model": "openai/gpt-5.6-sol", - "prompt": "hello coven", - "title": "Demo" - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_runtime( + let response = handle_request_with_body( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), - &runtime, + Some(r#"{"data": 42}"#), )?; - let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; - assert_eq!(response.status, 201); - assert!(response.body.contains(r#""status":"running""#)); - assert_eq!(runtime.launches.borrow().len(), 1); - assert_eq!(runtime.launches.borrow()[0].harness, "codex"); - assert_eq!( - runtime.launches.borrow()[0].model.as_deref(), - Some("openai/gpt-5.6-sol") - ); - assert_eq!( - runtime.launches.borrow()[0].launch_mode, - HarnessLaunchMode::Interactive - ); - assert_eq!(runtime.launches.borrow()[0].prompt, "hello coven"); - assert_eq!( - runtime.launches.borrow()[0].project_root, - project::canonical_project_root(&project_root)?.to_string_lossy() - ); - assert_eq!( - runtime.launches.borrow()[0].cwd, - project::resolve_inside_root( - &project::canonical_project_root(&project_root)?, - Some(&cwd) - )? - .to_string_lossy() + assert_eq!(response.status, 400); + assert!( + response.body.contains("invalid_request"), + "non-string `data` is a client error, expected 400 invalid_request, got: {}", + response.body ); - assert!(list.body.contains(r#""title":"Demo""#)); Ok(()) } #[test] - fn launch_request_accepts_non_interactive_mode_for_plain_chat() -> anyhow::Result<()> { + fn input_request_with_malformed_body_returns_400_not_daemon_crash() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "codex", - "launchMode": "nonInteractive", - "prompt": "hello coven" - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_runtime( + let response = handle_request_with_body( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), - &runtime, + Some("{ not json"), )?; - assert_eq!(response.status, 201); - assert_eq!( - runtime.launches.borrow()[0].launch_mode, - HarnessLaunchMode::NonInteractive + assert_eq!(response.status, 400); + assert!( + response.body.contains("invalid_request"), + "expected structured `invalid_request` code, got: {}", + response.body ); - assert!(runtime.launches.borrow()[0].model.is_none()); Ok(()) } #[test] - fn launch_request_threads_conversation_hint_through_to_runtime() -> anyhow::Result<()> { + fn input_request_not_live_runtime_error_routes_to_409_via_typed_downcast() -> anyhow::Result<()> + { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "hello", - "conversation": {"mode": "init", "id": "abc-123"} - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; + + struct NotLiveRuntime; + impl SessionRuntime for NotLiveRuntime { + fn launch_session(&self, _: &SessionLaunch) -> Result<()> { + Ok(()) + } + fn send_input(&self, _: &str, _: &Value) -> Result<()> { + Err(anyhow::Error::new(crate::daemon::NotLiveError { + session_id: "session-1".to_string(), + })) + } + fn kill_session(&self, _: &str) -> Result<()> { + Ok(()) + } + } + let runtime = NotLiveRuntime; let response = handle_request_with_runtime( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), + Some(r#"{"data":"hi"}"#), &runtime, )?; - assert_eq!(response.status, 201); - assert_eq!( - runtime.launches.borrow()[0].conversation, - Some(ConversationHint::Init { - id: "abc-123".to_string() - }) + assert_eq!(response.status, 409); + assert!( + response.body.contains("session_not_live"), + "typed NotLiveError must route to 409 session_not_live, got: {}", + response.body ); Ok(()) } #[test] - fn launch_request_accepts_resume_conversation_hint() -> anyhow::Result<()> { + fn input_request_runtime_failure_returns_500_not_daemon_crash() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "follow up", - "conversation": {"mode": "resume", "id": "abc-123"} - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; - handle_request_with_runtime( + // Runtime that fails send_input with a non-"not live" message + // — we want the daemon to surface it as a structured 500 + // instead of bubbling it to serve_next_connection's `?`. + struct FailingInput; + impl SessionRuntime for FailingInput { + fn launch_session(&self, _: &SessionLaunch) -> Result<()> { + Ok(()) + } + fn send_input(&self, _: &str, _: &Value) -> Result<()> { + Err(anyhow::anyhow!("simulated send_input failure")) + } + fn kill_session(&self, _: &str) -> Result<()> { + Ok(()) + } + } + let runtime = FailingInput; + + let response = handle_request_with_runtime( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), + Some(r#"{"data":"hello"}"#), &runtime, )?; - assert_eq!( - runtime.launches.borrow()[0].conversation, - Some(ConversationHint::Resume { - id: "abc-123".to_string() - }) + assert_eq!(response.status, 500); + assert!( + response.body.contains("send_input_failed"), + "expected structured `send_input_failed` code, got: {}", + response.body ); Ok(()) } #[test] - fn launch_request_persists_conversation_id_on_the_session_row() -> anyhow::Result<()> { + fn kill_request_runtime_failure_returns_500_not_daemon_crash() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "hi", - "conversation": {"mode": "init", "id": "abc-123"}, - "conversationId": "abc-123" - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; - handle_request_with_runtime( + struct FailingKill; + impl SessionRuntime for FailingKill { + fn launch_session(&self, _: &SessionLaunch) -> Result<()> { + Ok(()) + } + fn send_input(&self, _: &str, _: &Value) -> Result<()> { + Ok(()) + } + fn kill_session(&self, _: &str) -> Result<()> { + Err(anyhow::anyhow!("simulated kill_session failure")) + } + } + let runtime = FailingKill; + + let response = handle_request_with_runtime( "POST", - "/sessions", + "/sessions/session-1/kill", temp_dir.path(), None, - Some(&body), + Some("{}"), &runtime, )?; - assert_eq!( - runtime.launches.borrow()[0].conversation_id.as_deref(), - Some("abc-123") - ); - - // And it round-trips through the session list payload too. - let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; + assert_eq!(response.status, 500); assert!( - list.body.contains(r#""conversation_id":"abc-123""#), - "list response should expose conversation_id, got: {}", - list.body + response.body.contains("kill_failed"), + "expected structured `kill_failed` code, got: {}", + response.body ); Ok(()) } #[test] - fn launch_request_treats_missing_conversation_id_as_null() -> anyhow::Result<()> { + fn kill_request_marks_session_killed_and_records_event() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "hi" - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; - handle_request_with_runtime( - "POST", - "/sessions", - temp_dir.path(), - None, - Some(&body), - &runtime, - )?; + let response = handle_request("POST", "/sessions/session-1/kill", temp_dir.path(), None)?; + let detail = handle_request("GET", "/sessions/session-1", temp_dir.path(), None)?; + let events = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; - assert!(runtime.launches.borrow()[0].conversation_id.is_none()); + assert_eq!(response.status, 202); + assert!(detail.body.contains(r#""status":"killed""#)); + assert!(events.body.contains(r#""kind":"kill""#)); Ok(()) } #[test] - fn launch_request_persists_familiar_id_on_the_session_row() -> anyhow::Result<()> { + fn kill_request_invokes_live_runtime_hook() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - seed_familiars_toml(temp_dir.path())?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; + insert_test_session(temp_dir.path(), "session-1")?; let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "hi", - "familiarId": "sage" - }) - .to_string(); - handle_request_with_runtime( + let response = handle_request_with_runtime( "POST", - "/sessions", + "/sessions/session-1/kill", temp_dir.path(), None, - Some(&body), + None, &runtime, )?; - assert_eq!( - runtime.launches.borrow()[0].familiar_id.as_deref(), - Some("sage") - ); - - // And it round-trips through the session list payload too. - let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; - assert!( - list.body.contains(r#""familiar_id":"sage""#), - "list response should expose familiar_id, got: {}", - list.body - ); + assert_eq!(response.status, 202); + assert_eq!(runtime.kills.borrow().as_slice(), &["session-1"]); Ok(()) } #[test] - fn launch_request_rejects_unknown_familiar_id_without_inserting_session() -> anyhow::Result<()> - { + fn input_and_kill_reject_completed_sessions_as_not_live() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - seed_familiars_toml(temp_dir.path())?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "hi", - "familiarId": "missing" - }) - .to_string(); + insert_test_session_with_status(temp_dir.path(), "session-1", "completed")?; - let response = handle_request_with_runtime( + let input = handle_request_with_body( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), - &runtime, + Some(r#"{"data":"hello"}"#), )?; + let kill = handle_request("POST", "/sessions/session-1/kill", temp_dir.path(), None)?; - assert_eq!(response.status, 400); - assert!( - response.body.contains("unknown_familiar"), - "expected unknown familiar error, got: {}", - response.body - ); - assert!( - runtime.launches.borrow().is_empty(), - "unknown familiar must not launch a runtime" - ); - let list = handle_request("GET", "/sessions", temp_dir.path(), None)?; - assert!( - list.body == "[]", - "unknown familiar must not insert a session row, got: {}", - list.body - ); + assert_eq!(input.status, 409); + assert_eq!(kill.status, 409); + assert!(input.body.contains(r#""code":"session_not_live""#)); + assert!(kill.body.contains(r#""code":"session_not_live""#)); Ok(()) } #[test] - fn launch_request_reports_familiar_config_errors_without_launching() -> anyhow::Result<()> { + fn input_and_kill_reject_orphaned_sessions_as_not_live() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - std::fs::write(temp_dir.path().join("familiars.toml"), "[[familiar]\n")?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "hi", - "familiarId": "sage" - }) - .to_string(); + insert_test_session_with_status(temp_dir.path(), "session-1", "orphaned")?; - let response = handle_request_with_runtime( + let input = handle_request_with_body( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), - &runtime, + Some(r#"{"data":"hello"}"#), )?; + let kill = handle_request("POST", "/sessions/session-1/kill", temp_dir.path(), None)?; - assert_eq!(response.status, 500); - assert!( - response.body.contains("familiar_lookup_failed"), - "expected familiar config error, got: {}", - response.body - ); - assert!( - runtime.launches.borrow().is_empty(), - "malformed familiar config must not launch a runtime" - ); + assert_eq!(input.status, 409); + assert_eq!(kill.status, 409); + assert!(input.body.contains(r#""code":"session_not_live""#)); + assert!(kill.body.contains(r#""code":"session_not_live""#)); Ok(()) } #[test] - fn conversation_id_rejects_shell_metacharacters() { - // Ids carrying whitespace or shell metacharacters must be rejected so they - // can never reach the harness CLI's `--session-id`/`--resume`/`resume` argv, - // where on Windows a `.cmd` shim would re-parse cmd.exe metacharacters. - for bad in [ - "not-a-uuid & calc.exe", - "a|b", - "a b", - "$(whoami)", - "a\"b", - "a^b", - ] { - let payload = json!({"conversation": {"mode": "resume", "id": bad}}); - assert!( - conversation_from_payload(&payload).is_err(), - "expected {bad:?} to be rejected" - ); - } - // UUIDs and opaque slugs are accepted. - for good in [ - "11111111-2222-3333-4444-555555555555", - "abc-123", - "sess_1.2", - ] { - let payload = json!({"conversation": {"mode": "init", "id": good}}); - assert!( - conversation_from_payload(&payload) - .expect("shell-safe id should parse") - .is_some(), - "expected {good:?} to be accepted" - ); - } - } - - #[test] - fn launch_request_with_malformed_conversation_mode_returns_400_not_daemon_crash( - ) -> anyhow::Result<()> { + fn runtime_not_live_errors_become_conflict_responses() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "claude", - "launchMode": "nonInteractive", - "prompt": "hi", - "conversation": {"mode": "forge", "id": "abc"} - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; + let runtime = NotLiveRuntime; - let response = handle_request_with_runtime( + let input = handle_request_with_runtime( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), + Some(r#"{"data":"hello"}"#), + &runtime, + )?; + let kill = handle_request_with_runtime( + "POST", + "/sessions/session-1/kill", + temp_dir.path(), + None, + None, &runtime, )?; - // Must be a structured 400 — bubbling the error up to the - // daemon's accept loop would take the daemon down. - assert_eq!(response.status, 400); - assert!( - response.body.contains("conversation.mode"), - "expected body to mention conversation.mode, got: {}", - response.body - ); - assert!( - response.body.contains("invalid_request"), - "expected structured `invalid_request` code, got: {}", - response.body - ); - assert!(runtime.launches.borrow().is_empty()); + assert_eq!(input.status, 409); + assert_eq!(kill.status, 409); + assert!(input.body.contains(r#""code":"session_not_live""#)); + assert!(kill.body.contains(r#""code":"session_not_live""#)); Ok(()) } #[test] - fn launch_request_runtime_failure_returns_500_and_marks_session_failed() -> anyhow::Result<()> { + fn input_and_kill_reject_unknown_sessions() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = FailingLaunchRuntime; - let body = json!({ - "projectRoot": project_root, - "harness": "codex", - "prompt": "hello" - }) - .to_string(); - let response = handle_request_with_runtime( + let input = handle_request_with_body( "POST", - "/sessions", + "/sessions/missing/input", temp_dir.path(), None, - Some(&body), - &runtime, + Some(r#"{"data":"hello"}"#), )?; - let sessions = handle_request("GET", "/sessions", temp_dir.path(), None)?; + let kill = handle_request("POST", "/sessions/missing/kill", temp_dir.path(), None)?; - // Must be a structured response — propagating Err would crash - // the daemon's accept loop. - assert_eq!(response.status, 500); - assert!( - response.body.contains("launch_failed"), - "expected structured `launch_failed` code, got: {}", - response.body - ); - assert!( - response.body.contains("launch failed"), - "expected runtime error message in the body, got: {}", - response.body - ); - assert!(sessions.body.contains(r#""status":"failed""#)); + assert_eq!(input.status, 404); + assert_eq!(kill.status, 404); + assert!(input.body.contains(r#""code":"session_not_found""#)); + assert!(kill.body.contains(r#""code":"session_not_found""#)); + Ok(()) + } + + #[derive(Default)] + struct RecordingRuntime { + launches: std::cell::RefCell>, + inputs: std::cell::RefCell>, + kills: std::cell::RefCell>, + } + + impl SessionRuntime for RecordingRuntime { + fn launch_session(&self, launch: &SessionLaunch) -> Result<()> { + self.launches.borrow_mut().push(launch.clone()); + Ok(()) + } + + fn send_input(&self, session_id: &str, payload: &Value) -> Result<()> { + let data = payload + .get("data") + .and_then(Value::as_str) + .unwrap_or_default(); + self.inputs + .borrow_mut() + .push(format!("{session_id}:{data}")); + Ok(()) + } + + fn kill_session(&self, session_id: &str) -> Result<()> { + self.kills.borrow_mut().push(session_id.to_string()); + Ok(()) + } + } + + struct FailingLaunchRuntime; + + impl SessionRuntime for FailingLaunchRuntime { + fn launch_session(&self, _launch: &SessionLaunch) -> Result<()> { + anyhow::bail!("launch failed") + } + + fn send_input(&self, _session_id: &str, _payload: &Value) -> Result<()> { + Ok(()) + } + + fn kill_session(&self, _session_id: &str) -> Result<()> { + Ok(()) + } + } + + struct NotLiveRuntime; + + impl SessionRuntime for NotLiveRuntime { + fn launch_session(&self, _launch: &SessionLaunch) -> Result<()> { + Ok(()) + } + + fn send_input(&self, session_id: &str, _payload: &Value) -> Result<()> { + Err(anyhow::Error::new(crate::daemon::NotLiveError { + session_id: session_id.to_string(), + })) + } + + fn kill_session(&self, session_id: &str) -> Result<()> { + Err(anyhow::Error::new(crate::daemon::NotLiveError { + session_id: session_id.to_string(), + })) + } + } + + fn insert_test_session(coven_home: &std::path::Path, id: &str) -> anyhow::Result<()> { + insert_test_session_with_status(coven_home, id, "running") + } + + fn insert_test_session_with_status( + coven_home: &std::path::Path, + id: &str, + status: &str, + ) -> anyhow::Result<()> { + let conn = crate::store::open_store(&coven_home.join("coven.sqlite3"))?; + let session = crate::store::SessionRecord { + id: id.to_string(), + project_root: "/repo".to_string(), + harness: "codex".to_string(), + title: "hello from coven".to_string(), + status: status.to_string(), + exit_code: None, + archived_at: None, + created_at: "2026-04-27T10:00:00Z".to_string(), + updated_at: "2026-04-27T10:00:00Z".to_string(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }; + crate::store::insert_session(&conn, &session)?; Ok(()) } #[test] - fn launch_request_rejects_cwd_outside_project_root() -> anyhow::Result<()> { + fn events_response_has_paginated_envelope_with_next_cursor() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - let outside = temp_dir.path().join("outside"); - std::fs::create_dir_all(&project_root)?; - std::fs::create_dir_all(&outside)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "cwd": outside, - "harness": "codex", - "prompt": "hello" - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_runtime( + handle_request_with_body( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(&body), - &runtime, + Some(r#"{"data":"first"}"#), )?; - - assert_eq!(response.status, 400); - assert!( - response.body.contains("outside the Coven project root"), - "unexpected body: {}", - response.body - ); - assert!(runtime.launches.borrow().is_empty()); - Ok(()) - } - - #[test] - fn launch_request_rejects_missing_required_fields() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - let runtime = RecordingRuntime::default(); - - let response = handle_request_with_runtime( + handle_request_with_body( "POST", - "/sessions", + "/sessions/session-1/input", temp_dir.path(), None, - Some(r#"{"harness":"codex"}"#), - &runtime, + Some(r#"{"data":"second"}"#), )?; - assert_eq!(response.status, 400); - assert!(response.body.contains("projectRoot")); - assert!(response.body.contains("invalid_request")); - assert!(runtime.launches.borrow().is_empty()); + let events = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; + + assert_eq!(events.status, 200); + let body: serde_json::Value = serde_json::from_str(&events.body)?; + assert!(body["events"].is_array()); + assert_eq!(body["events"].as_array().unwrap().len(), 2); + assert!(body["events"][0]["seq"].as_i64().unwrap() > 0); + assert!( + body["events"][1]["seq"].as_i64().unwrap() > body["events"][0]["seq"].as_i64().unwrap() + ); + assert!(body["nextCursor"]["afterSeq"].as_i64().is_some()); + assert_eq!(body["hasMore"], false); Ok(()) } #[test] - fn input_request_records_session_event() -> anyhow::Result<()> { + fn events_endpoint_supports_after_seq_cursor() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_body( - "POST", - "/sessions/session-1/input", + for data in &["a", "b", "c"] { + handle_request_with_body( + "POST", + "/sessions/session-1/input", + temp_dir.path(), + None, + Some(&format!(r#"{{"data":"{data}"}}"#)), + )?; + } + + let all = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; + let all_body: serde_json::Value = serde_json::from_str(&all.body)?; + let first_seq = all_body["events"][0]["seq"].as_i64().unwrap(); + + let after = handle_request( + "GET", + &format!("/events?sessionId=session-1&afterSeq={first_seq}"), temp_dir.path(), None, - Some(r#"{"data":"hello coven"}"#), )?; - let events = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; - - assert_eq!(response.status, 202); - assert!(response.body.contains(r#""accepted":true"#)); - assert_eq!(events.status, 200); - assert!(events.body.contains(r#""kind":"input""#)); - assert!(events.body.contains("hello coven")); + let after_body: serde_json::Value = serde_json::from_str(&after.body)?; + assert_eq!(after.status, 200); + assert_eq!(after_body["events"].as_array().unwrap().len(), 2); Ok(()) } #[test] - fn input_request_invokes_live_runtime_hook() -> anyhow::Result<()> { + fn events_endpoint_supports_limit_param() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; insert_test_session(temp_dir.path(), "session-1")?; - let runtime = RecordingRuntime::default(); - let response = handle_request_with_runtime( - "POST", - "/sessions/session-1/input", + for data in &["a", "b", "c", "d"] { + handle_request_with_body( + "POST", + "/sessions/session-1/input", + temp_dir.path(), + None, + Some(&format!(r#"{{"data":"{data}"}}"#)), + )?; + } + + let limited = handle_request( + "GET", + "/events?sessionId=session-1&limit=2", temp_dir.path(), None, - Some(r#"{"data":"hello coven"}"#), - &runtime, )?; - - assert_eq!(response.status, 202); - assert_eq!( - runtime.inputs.borrow().as_slice(), - &["session-1:hello coven"] - ); + let body: serde_json::Value = serde_json::from_str(&limited.body)?; + assert_eq!(limited.status, 200); + assert_eq!(body["events"].as_array().unwrap().len(), 2); + assert_eq!(body["hasMore"], true); Ok(()) } #[test] - fn launch_request_with_unknown_harness_returns_400_upfront_no_session_row() -> anyhow::Result<()> - { + fn events_endpoint_combines_after_seq_cursor_with_limit() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let project_root = temp_dir.path().join("repo"); - std::fs::create_dir_all(&project_root)?; - let runtime = RecordingRuntime::default(); - let body = json!({ - "projectRoot": project_root, - "harness": "hermes", - "launchMode": "nonInteractive", - "prompt": "hello" - }) - .to_string(); + insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_runtime( - "POST", - "/sessions", + for data in &["a", "b", "c"] { + handle_request_with_body( + "POST", + "/sessions/session-1/input", + temp_dir.path(), + None, + Some(&format!(r#"{{"data":"{data}"}}"#)), + )?; + } + + let all = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; + let all_body: serde_json::Value = serde_json::from_str(&all.body)?; + let first_seq = all_body["events"][0]["seq"].as_i64().unwrap(); + + let page = handle_request( + "GET", + &format!("/events?sessionId=session-1&afterSeq={first_seq}&limit=1"), temp_dir.path(), None, - Some(&body), - &runtime, )?; - assert_eq!(response.status, 400); - assert!( - response.body.contains("unsupported harness `hermes`"), - "expected unsupported-harness validation message, got: {}", - response.body - ); - assert!( - response - .body - .contains(crate::harness::EXTERNAL_ADAPTER_MANIFEST_ENV), - "expected external adapter manifest guidance, got: {}", - response.body - ); - assert!( - runtime.launches.borrow().is_empty(), - "runtime must not be invoked for an unsupported harness" - ); - // And no session row should have been inserted. - let sessions = handle_request("GET", "/sessions", temp_dir.path(), None)?; - assert!( - sessions.body.contains("[]"), - "no session row should exist after a 400-rejected launch, got: {}", - sessions.body - ); + let body: serde_json::Value = serde_json::from_str(&page.body)?; + assert_eq!(page.status, 200); + assert_eq!(body["events"].as_array().unwrap().len(), 1); + assert!(body["events"][0]["seq"].as_i64().unwrap() > first_seq); + assert_eq!(body["nextCursor"]["afterSeq"], body["events"][0]["seq"]); + assert_eq!(body["hasMore"], true); Ok(()) } #[test] - fn input_request_with_missing_data_field_returns_400_not_500() -> anyhow::Result<()> { + fn events_endpoint_supports_after_event_id_cursor() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_body( - "POST", - "/sessions/session-1/input", + for data in &["a", "b", "c"] { + handle_request_with_body( + "POST", + "/sessions/session-1/input", + temp_dir.path(), + None, + Some(&format!(r#"{{"data":"{data}"}}"#)), + )?; + } + + let all = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; + let all_body: serde_json::Value = serde_json::from_str(&all.body)?; + let first_event_id = all_body["events"][0]["id"].as_str().unwrap(); + let second_event_id = all_body["events"][1]["id"].as_str().unwrap(); + let third_event_id = all_body["events"][2]["id"].as_str().unwrap(); + + let after = handle_request( + "GET", + &format!("/events?sessionId=session-1&afterEventId={first_event_id}"), temp_dir.path(), None, - Some(r#"{"foo":"bar"}"#), )?; - - assert_eq!(response.status, 400); - assert!( - response.body.contains("invalid_request"), - "missing `data` is a client error, expected 400 invalid_request, got: {}", - response.body - ); - assert!( - response.body.contains("`data`"), - "expected the message to name the missing field, got: {}", - response.body - ); + let after_body: serde_json::Value = serde_json::from_str(&after.body)?; + assert_eq!(after.status, 200); + assert_eq!(after_body["events"].as_array().unwrap().len(), 2); + assert_eq!(after_body["events"][0]["id"], second_event_id); + assert_eq!(after_body["events"][1]["id"], third_event_id); Ok(()) } #[test] - fn input_request_with_non_string_data_field_returns_400_not_500() -> anyhow::Result<()> { + fn events_endpoint_clamps_zero_limit_to_one_event() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_body( - "POST", - "/sessions/session-1/input", + for data in &["a", "b"] { + handle_request_with_body( + "POST", + "/sessions/session-1/input", + temp_dir.path(), + None, + Some(&format!(r#"{{"data":"{data}"}}"#)), + )?; + } + + let response = handle_request( + "GET", + "/events?sessionId=session-1&limit=0", temp_dir.path(), None, - Some(r#"{"data": 42}"#), )?; + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(response.status, 200); + assert_eq!(body["events"].as_array().unwrap().len(), 1); + assert_eq!(body["hasMore"], true); + Ok(()) + } + + #[test] + fn events_endpoint_returns_structured_error_for_missing_session_id() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let response = handle_request("GET", "/events", temp_dir.path(), None)?; + assert_eq!(response.status, 400); - assert!( - response.body.contains("invalid_request"), - "non-string `data` is a client error, expected 400 invalid_request, got: {}", - response.body - ); + assert!(response.body.contains(r#""code":"invalid_request""#)); Ok(()) } #[test] - fn input_request_with_malformed_body_returns_400_not_daemon_crash() -> anyhow::Result<()> { + fn events_endpoint_returns_structured_error_for_non_integer_limit() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; insert_test_session(temp_dir.path(), "session-1")?; - let response = handle_request_with_body( - "POST", - "/sessions/session-1/input", + let response = handle_request( + "GET", + "/events?sessionId=session-1&limit=foo", temp_dir.path(), None, - Some("{ not json"), )?; assert_eq!(response.status, 400); - assert!( - response.body.contains("invalid_request"), - "expected structured `invalid_request` code, got: {}", - response.body - ); + assert!(response.body.contains(r#""code":"invalid_request""#)); + assert!(response.body.contains(r#""limit":"foo""#)); Ok(()) } #[test] - fn input_request_not_live_runtime_error_routes_to_409_via_typed_downcast() -> anyhow::Result<()> - { + fn events_endpoint_returns_structured_error_for_non_integer_after_seq() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; insert_test_session(temp_dir.path(), "session-1")?; - struct NotLiveRuntime; - impl SessionRuntime for NotLiveRuntime { - fn launch_session(&self, _: &SessionLaunch) -> Result<()> { - Ok(()) - } - fn send_input(&self, _: &str, _: &Value) -> Result<()> { - Err(anyhow::Error::new(crate::daemon::NotLiveError { - session_id: "session-1".to_string(), - })) - } - fn kill_session(&self, _: &str) -> Result<()> { - Ok(()) - } - } - let runtime = NotLiveRuntime; - - let response = handle_request_with_runtime( - "POST", - "/sessions/session-1/input", + let response = handle_request( + "GET", + "/events?sessionId=session-1&afterSeq=foo", temp_dir.path(), None, - Some(r#"{"data":"hi"}"#), - &runtime, )?; - assert_eq!(response.status, 409); - assert!( - response.body.contains("session_not_live"), - "typed NotLiveError must route to 409 session_not_live, got: {}", - response.body - ); + assert_eq!(response.status, 400); + assert!(response.body.contains(r#""code":"invalid_request""#)); + assert!(response.body.contains(r#""afterSeq":"foo""#)); Ok(()) } #[test] - fn input_request_runtime_failure_returns_500_not_daemon_crash() -> anyhow::Result<()> { + fn events_endpoint_validates_limit_before_session_lookup() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - - // Runtime that fails send_input with a non-"not live" message - // — we want the daemon to surface it as a structured 500 - // instead of bubbling it to serve_next_connection's `?`. - struct FailingInput; - impl SessionRuntime for FailingInput { - fn launch_session(&self, _: &SessionLaunch) -> Result<()> { - Ok(()) - } - fn send_input(&self, _: &str, _: &Value) -> Result<()> { - Err(anyhow::anyhow!("simulated send_input failure")) - } - fn kill_session(&self, _: &str) -> Result<()> { - Ok(()) - } - } - let runtime = FailingInput; - - let response = handle_request_with_runtime( - "POST", - "/sessions/session-1/input", + let response = handle_request( + "GET", + "/events?sessionId=ghost&limit=foo", temp_dir.path(), None, - Some(r#"{"data":"hello"}"#), - &runtime, )?; - assert_eq!(response.status, 500); - assert!( - response.body.contains("send_input_failed"), - "expected structured `send_input_failed` code, got: {}", - response.body - ); + assert_eq!(response.status, 400); + assert!(response.body.contains(r#""code":"invalid_request""#)); + assert!(response.body.contains(r#""limit":"foo""#)); Ok(()) } #[test] - fn kill_request_runtime_failure_returns_500_not_daemon_crash() -> anyhow::Result<()> { + fn events_endpoint_validates_after_seq_before_session_lookup() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - - struct FailingKill; - impl SessionRuntime for FailingKill { - fn launch_session(&self, _: &SessionLaunch) -> Result<()> { - Ok(()) - } - fn send_input(&self, _: &str, _: &Value) -> Result<()> { - Ok(()) - } - fn kill_session(&self, _: &str) -> Result<()> { - Err(anyhow::anyhow!("simulated kill_session failure")) - } - } - let runtime = FailingKill; - - let response = handle_request_with_runtime( - "POST", - "/sessions/session-1/kill", + let response = handle_request( + "GET", + "/events?sessionId=ghost&afterSeq=foo", temp_dir.path(), None, - Some("{}"), - &runtime, )?; - assert_eq!(response.status, 500); - assert!( - response.body.contains("kill_failed"), - "expected structured `kill_failed` code, got: {}", - response.body - ); + assert_eq!(response.status, 400); + assert!(response.body.contains(r#""code":"invalid_request""#)); + assert!(response.body.contains(r#""afterSeq":"foo""#)); Ok(()) } #[test] - fn kill_request_marks_session_killed_and_records_event() -> anyhow::Result<()> { + fn events_endpoint_returns_structured_error_for_unknown_session() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - - let response = handle_request("POST", "/sessions/session-1/kill", temp_dir.path(), None)?; - let detail = handle_request("GET", "/sessions/session-1", temp_dir.path(), None)?; - let events = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; + let response = handle_request("GET", "/events?sessionId=ghost", temp_dir.path(), None)?; - assert_eq!(response.status, 202); - assert!(detail.body.contains(r#""status":"killed""#)); - assert!(events.body.contains(r#""kind":"kill""#)); + assert_eq!(response.status, 404); + assert!(response.body.contains(r#""code":"session_not_found""#)); Ok(()) } #[test] - fn kill_request_invokes_live_runtime_hook() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - let runtime = RecordingRuntime::default(); + fn get_session_log_returns_log_lines_from_events() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let conn = store::open_store(&store_path(home))?; + let session = store::SessionRecord { + id: "sess-log".into(), + project_root: "/tmp/proj".into(), + harness: "claude".into(), + title: "demo".into(), + status: "running".into(), + exit_code: None, + archived_at: None, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }; + store::insert_session(&conn, &session)?; + insert_event(&conn, home, "sess-log", "input", json!({"text": "hello"}))?; + insert_event(&conn, home, "sess-log", "output", json!({"text": "world"}))?; + insert_event(&conn, home, "sess-log", "error", json!({"message": "boom"}))?; + drop(conn); - let response = handle_request_with_runtime( - "POST", - "/sessions/session-1/kill", - temp_dir.path(), - None, - None, - &runtime, + let response = handle_request("GET", "/api/v1/sessions/sess-log/log", home, None)?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + let lines = body.as_array().expect("array body"); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0]["level"], "info"); + assert!(lines[0]["message"].as_str().unwrap().contains("hello")); + assert_eq!(lines[2]["level"], "error"); + assert!(lines[2]["message"].as_str().unwrap().contains("boom")); + Ok(()) + } + + #[test] + fn logs_and_events_return_redacted_payloads_by_default() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let conn = store::open_store(&store_path(home))?; + let session = store::SessionRecord { + id: "sess-secret".into(), + project_root: "/tmp/proj".into(), + harness: "codex".into(), + title: "demo".into(), + status: "running".into(), + exit_code: None, + archived_at: None, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }; + store::insert_session(&conn, &session)?; + let fake = fake_openai_key(); + insert_event( + &conn, + home, + "sess-secret", + "input", + json!({"data": format!("Authorization: Bearer {fake}")}), )?; + drop(conn); - assert_eq!(response.status, 202); - assert_eq!(runtime.kills.borrow().as_slice(), &["session-1"]); + let log = handle_request("GET", "/api/v1/sessions/sess-secret/log", home, None)?; + let events = handle_request("GET", "/api/v1/events?sessionId=sess-secret", home, None)?; + let alias = handle_request("GET", "/api/v1/sessions/sess-secret/events", home, None)?; + + for response in [log, events, alias] { + assert_eq!(response.status, 200); + assert!(!response.body.contains(&fake)); + assert!(response.body.contains("[REDACTED]")); + } Ok(()) } #[test] - fn input_and_kill_reject_completed_sessions_as_not_live() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session_with_status(temp_dir.path(), "session-1", "completed")?; + fn raw_artifact_endpoint_is_disabled_by_default() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let conn = store::open_store(&store_path(home))?; + let session = store::SessionRecord { + id: "sess-secret".into(), + project_root: "/tmp/proj".into(), + harness: "codex".into(), + title: "demo".into(), + status: "running".into(), + exit_code: None, + archived_at: None, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }; + store::insert_session(&conn, &session)?; + drop(conn); - let input = handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), + let response = handle_request( + "GET", + "/api/v1/sessions/sess-secret/artifacts/event-1?raw=1", + home, None, - Some(r#"{"data":"hello"}"#), )?; - let kill = handle_request("POST", "/sessions/session-1/kill", temp_dir.path(), None)?; - assert_eq!(input.status, 409); - assert_eq!(kill.status, 409); - assert!(input.body.contains(r#""code":"session_not_live""#)); - assert!(kill.body.contains(r#""code":"session_not_live""#)); + assert_eq!(response.status, 403); + assert!(response.body.contains(r#""code":"raw_artifacts_disabled""#)); + assert!(!response.body.contains("payload")); Ok(()) } #[test] - fn input_and_kill_reject_orphaned_sessions_as_not_live() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session_with_status(temp_dir.path(), "session-1", "orphaned")?; + fn raw_artifact_endpoint_returns_404_for_expired_artifact() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + std::fs::write(home.join("privacy.toml"), "persist_raw_artifacts = true\n")?; + let conn = store::open_store(&store_path(home))?; + let session = store::SessionRecord { + id: "sess-secret".into(), + project_root: "/tmp/proj".into(), + harness: "codex".into(), + title: "demo".into(), + status: "running".into(), + exit_code: None, + archived_at: None, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }; + store::insert_session(&conn, &session)?; + insert_event( + &conn, + home, + "sess-secret", + "input", + json!({"data": "secret"}), + )?; + let event_id = store::list_events(&conn, "sess-secret")? + .pop() + .expect("event") + .id; + store::insert_sensitive_artifact( + &conn, + &store::SensitiveArtifactRecord { + id: "artifact-1".into(), + session_id: "sess-secret".into(), + event_id, + kind: "input".into(), + nonce: vec![0; 24], + ciphertext: vec![1, 2, 3], + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2026-01-02T00:00:00Z".into(), + }, + )?; + drop(conn); - let input = handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), + let response = handle_request( + "GET", + "/api/v1/sessions/sess-secret/artifacts/artifact-1?raw=1", + home, None, - Some(r#"{"data":"hello"}"#), )?; - let kill = handle_request("POST", "/sessions/session-1/kill", temp_dir.path(), None)?; - assert_eq!(input.status, 409); - assert_eq!(kill.status, 409); - assert!(input.body.contains(r#""code":"session_not_live""#)); - assert!(kill.body.contains(r#""code":"session_not_live""#)); + assert_eq!(response.status, 404); + assert!(response.body.contains(r#""code":"artifact_expired""#)); + assert!(!response.body.contains("payload")); Ok(()) } #[test] - fn runtime_not_live_errors_become_conflict_responses() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - let runtime = NotLiveRuntime; - - let input = handle_request_with_runtime( - "POST", - "/sessions/session-1/input", - temp_dir.path(), - None, - Some(r#"{"data":"hello"}"#), - &runtime, - )?; - let kill = handle_request_with_runtime( - "POST", - "/sessions/session-1/kill", - temp_dir.path(), - None, - None, - &runtime, - )?; - - assert_eq!(input.status, 409); - assert_eq!(kill.status, 409); - assert!(input.body.contains(r#""code":"session_not_live""#)); - assert!(kill.body.contains(r#""code":"session_not_live""#)); + fn get_session_log_returns_404_for_unknown_session() -> Result<()> { + let temp = tempfile::tempdir()?; + let response = handle_request("GET", "/api/v1/sessions/missing/log", temp.path(), None)?; + assert_eq!(response.status, 404); + assert!( + response.body.contains(r#""sessionId":"missing""#), + "expected sessionId 'missing' (not 'missing/log'); got: {}", + response.body + ); Ok(()) } #[test] - fn input_and_kill_reject_unknown_sessions() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - - let input = handle_request_with_body( + fn post_cast_records_event_and_returns_result() -> Result<()> { + let temp = tempfile::tempdir()?; + let body = json!({ + "code": "/status", + "target": null, + }); + let response = handle_request_with_body( "POST", - "/sessions/missing/input", - temp_dir.path(), + "/api/v1/cast", + temp.path(), None, - Some(r#"{"data":"hello"}"#), + Some(&body.to_string()), )?; - let kill = handle_request("POST", "/sessions/missing/kill", temp_dir.path(), None)?; - - assert_eq!(input.status, 404); - assert_eq!(kill.status, 404); - assert!(input.body.contains(r#""code":"session_not_found""#)); - assert!(kill.body.contains(r#""code":"session_not_found""#)); - Ok(()) - } - - #[derive(Default)] - struct RecordingRuntime { - launches: std::cell::RefCell>, - inputs: std::cell::RefCell>, - kills: std::cell::RefCell>, - } - - impl SessionRuntime for RecordingRuntime { - fn launch_session(&self, launch: &SessionLaunch) -> Result<()> { - self.launches.borrow_mut().push(launch.clone()); - Ok(()) - } - - fn send_input(&self, session_id: &str, payload: &Value) -> Result<()> { - let data = payload - .get("data") - .and_then(Value::as_str) - .unwrap_or_default(); - self.inputs - .borrow_mut() - .push(format!("{session_id}:{data}")); - Ok(()) - } - - fn kill_session(&self, session_id: &str) -> Result<()> { - self.kills.borrow_mut().push(session_id.to_string()); - Ok(()) - } - } - - struct FailingLaunchRuntime; - - impl SessionRuntime for FailingLaunchRuntime { - fn launch_session(&self, _launch: &SessionLaunch) -> Result<()> { - anyhow::bail!("launch failed") - } - - fn send_input(&self, _session_id: &str, _payload: &Value) -> Result<()> { - Ok(()) - } - - fn kill_session(&self, _session_id: &str) -> Result<()> { - Ok(()) - } - } - - struct NotLiveRuntime; - - impl SessionRuntime for NotLiveRuntime { - fn launch_session(&self, _launch: &SessionLaunch) -> Result<()> { - Ok(()) - } - - fn send_input(&self, session_id: &str, _payload: &Value) -> Result<()> { - Err(anyhow::Error::new(crate::daemon::NotLiveError { - session_id: session_id.to_string(), - })) - } - - fn kill_session(&self, session_id: &str) -> Result<()> { - Err(anyhow::Error::new(crate::daemon::NotLiveError { - session_id: session_id.to_string(), - })) - } + assert_eq!(response.status, 202); + let result: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(result["accepted"], true); + assert!(result["cast_id"] + .as_str() + .expect("cast_id") + .starts_with("cast-")); + assert_eq!(result["echo"], "/status"); + Ok(()) } - fn insert_test_session(coven_home: &std::path::Path, id: &str) -> anyhow::Result<()> { - insert_test_session_with_status(coven_home, id, "running") + #[test] + fn post_cast_rejects_missing_code() -> Result<()> { + let temp = tempfile::tempdir()?; + let body = json!({ "target": "sess-1" }); + let response = handle_request_with_body( + "POST", + "/api/v1/cast", + temp.path(), + None, + Some(&body.to_string()), + )?; + assert_eq!(response.status, 400); + Ok(()) } - fn insert_test_session_with_status( - coven_home: &std::path::Path, - id: &str, - status: &str, - ) -> anyhow::Result<()> { - let conn = crate::store::open_store(&coven_home.join("coven.sqlite3"))?; - let session = crate::store::SessionRecord { - id: id.to_string(), - project_root: "/repo".to_string(), - harness: "codex".to_string(), - title: "hello from coven".to_string(), - status: status.to_string(), + #[test] + fn post_cast_with_target_logs_event_to_session() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + + let conn = store::open_store(&store_path(home))?; + let session = store::SessionRecord { + id: "sess-target".into(), + project_root: "/tmp/proj".into(), + harness: "claude".into(), + title: "demo".into(), + status: "running".into(), exit_code: None, archived_at: None, - created_at: "2026-04-27T10:00:00Z".to_string(), - updated_at: "2026-04-27T10:00:00Z".to_string(), + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), conversation_id: None, familiar_id: None, labels: Vec::new(), @@ -5663,1034 +7725,1163 @@ mod tests { external: false, transcript_path: None, }; - crate::store::insert_session(&conn, &session)?; + store::insert_session(&conn, &session)?; + drop(conn); + + let body = json!({ "code": "/handoff", "target": "sess-target" }); + let response = + handle_request_with_body("POST", "/api/v1/cast", home, None, Some(&body.to_string()))?; + assert_eq!(response.status, 202); + let result: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(result["accepted"], true); + assert_eq!(result["echo"], "/handoff → sess-target"); + + // Verify the cast landed as an event on the target session, not __cockpit__. + let log_response = handle_request("GET", "/api/v1/sessions/sess-target/log", home, None)?; + assert_eq!(log_response.status, 200); + let lines: serde_json::Value = serde_json::from_str(&log_response.body)?; + let arr = lines.as_array().expect("array body"); + assert_eq!(arr.len(), 1); + assert!( + arr[0]["message"].as_str().unwrap().contains("/handoff"), + "expected log message to contain code; got: {}", + arr[0]["message"] + ); Ok(()) } #[test] - fn events_response_has_paginated_envelope_with_next_cursor() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - - handle_request_with_body( + fn post_cast_with_unknown_target_returns_404() -> Result<()> { + let temp = tempfile::tempdir()?; + let body = json!({ "code": "/status", "target": "no-such-session" }); + let response = handle_request_with_body( "POST", - "/sessions/session-1/input", - temp_dir.path(), + "/api/v1/cast", + temp.path(), None, - Some(r#"{"data":"first"}"#), + Some(&body.to_string()), )?; - handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), - None, - Some(r#"{"data":"second"}"#), + assert_eq!(response.status, 404); + assert!( + response.body.contains(r#""code":"session_not_found""#), + "expected session_not_found body; got: {}", + response.body + ); + assert!( + response.body.contains(r#""sessionId":"no-such-session""#), + "expected sessionId in error details; got: {}", + response.body + ); + Ok(()) + } + + #[test] + fn post_cast_without_target_idempotently_uses_cockpit_session() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let body = json!({ "code": "/status" }); + for _ in 0..3 { + let response = handle_request_with_body( + "POST", + "/api/v1/cast", + home, + None, + Some(&body.to_string()), + )?; + assert_eq!(response.status, 202); + } + // Only one __cockpit__ row should exist; all three casts land as events on it. + let conn = store::open_store(&store_path(home))?; + let sessions = store::list_sessions(&conn)?; + let cockpit_count = sessions.iter().filter(|s| s.id == "__cockpit__").count(); + assert_eq!( + cockpit_count, 1, + "expected exactly one __cockpit__ session row" + ); + Ok(()) + } + + #[test] + fn get_overview_returns_session_count_and_zeroed_unknowns() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let conn = store::open_store(&store_path(home))?; + for id in ["s1", "s2", "s3"] { + let now = "2026-01-01T00:00:00Z"; + let status = if id == "s3" { "ended" } else { "running" }; + store::insert_session( + &conn, + &store::SessionRecord { + id: id.into(), + project_root: "/tmp".into(), + harness: "claude".into(), + title: "t".into(), + status: status.into(), + exit_code: None, + archived_at: None, + created_at: now.into(), + updated_at: now.into(), + conversation_id: None, + familiar_id: None, + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }, + )?; + } + drop(conn); + + let response = handle_request("GET", "/api/v1/overview", home, None)?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["open_sessions"], 2); + assert_eq!(body["active_familiars"], 0); + assert_eq!(body["skills_count"], 0); + Ok(()) + } + + #[test] + fn get_overview_counts_familiars_skills_and_research_from_local_sources() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + + std::fs::write( + home.join("familiars.toml"), + r#" +[[familiar]] +id = "charm" +display_name = "Charm" +role = "steward" +description = "keeps the hearth" + +[[familiar]] +id = "sage" +display_name = "Sage" +role = "researcher" +description = "digs deep" +"#, )?; - let events = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; + for skill in ["eval-loop", "stream-scribe"] { + let dir = home.join("skills").join(skill); + std::fs::create_dir_all(&dir)?; + std::fs::write( + dir.join("metadata.json"), + format!(r#"{{"name":"{skill}","description":"a skill","version":"1.0.0"}}"#), + )?; + } - assert_eq!(events.status, 200); - let body: serde_json::Value = serde_json::from_str(&events.body)?; - assert!(body["events"].is_array()); - assert_eq!(body["events"].as_array().unwrap().len(), 2); - assert!(body["events"][0]["seq"].as_i64().unwrap() > 0); - assert!( - body["events"][1]["seq"].as_i64().unwrap() > body["events"][0]["seq"].as_i64().unwrap() - ); - assert!(body["nextCursor"]["afterSeq"].as_i64().is_some()); - assert_eq!(body["hasMore"], false); + let research_dir = home.join("research"); + std::fs::create_dir_all(&research_dir)?; + std::fs::write( + research_dir.join("results.tsv"), + "1\tharness capabilities\t7.5\t1.5\tcontinue\tnotes.md\n\ + 2\tstream continuity\t9.0\t2.0\tadopt\tnotes.md\n", + )?; + + let conn = store::open_store(&store_path(home))?; + let now = "2026-01-01T00:00:00Z"; + for (id, status, familiar) in [ + ("s1", "running", Some("charm")), + ("s2", "running", Some("charm")), + ("s3", "running", Some("ghost-not-in-roster")), + ("s4", "ended", Some("sage")), + ] { + store::insert_session( + &conn, + &store::SessionRecord { + id: id.into(), + project_root: "/tmp".into(), + harness: "claude".into(), + title: "t".into(), + status: status.into(), + exit_code: None, + archived_at: None, + created_at: now.into(), + updated_at: now.into(), + conversation_id: None, + familiar_id: familiar.map(str::to_string), + labels: Vec::new(), + visibility: "private".to_string(), + external: false, + transcript_path: None, + }, + )?; + } + drop(conn); + + let response = handle_request("GET", "/api/v1/overview", home, None)?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["open_sessions"], 3); + assert_eq!(body["total_familiars"], 2); + // Only roster familiars with an open session count as active: charm + // (running twice, deduped); sage's session ended; the ghost id is not + // in the roster. + assert_eq!(body["active_familiars"], 1); + assert_eq!(body["skills_count"], 2); + // Skill scores are stubbed at 0.0 until scoring lands. + assert_eq!(body["average_skill_score"], 0); + assert_eq!(body["research_iterations"], 2); + assert_eq!(body["last_research_delta"], 2); Ok(()) } #[test] - fn events_endpoint_supports_after_seq_cursor() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - - for data in &["a", "b", "c"] { - handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), - None, - Some(&format!(r#"{{"data":"{data}"}}"#)), - )?; + fn empty_array_stubs_return_200_with_empty_json_array() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + for route in [ + "/api/v1/familiars", + "/api/v1/skills", + "/api/v1/memory", + "/api/v1/research", + ] { + let response = handle_request("GET", route, home, None)?; + assert_eq!(response.status, 200, "route {route}"); + assert_eq!(response.content_type, "application/json", "route {route}"); + assert_eq!(response.body, "[]", "route {route}"); } - - let all = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; - let all_body: serde_json::Value = serde_json::from_str(&all.body)?; - let first_seq = all_body["events"][0]["seq"].as_i64().unwrap(); - - let after = handle_request( - "GET", - &format!("/events?sessionId=session-1&afterSeq={first_seq}"), - temp_dir.path(), - None, - )?; - let after_body: serde_json::Value = serde_json::from_str(&after.body)?; - assert_eq!(after.status, 200); - assert_eq!(after_body["events"].as_array().unwrap().len(), 2); Ok(()) } #[test] - fn events_endpoint_supports_limit_param() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - - for data in &["a", "b", "c", "d"] { - handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), - None, - Some(&format!(r#"{{"data":"{data}"}}"#)), - )?; + fn get_cast_codes_returns_grammar_templates() -> Result<()> { + let temp = tempfile::tempdir()?; + let response = handle_request("GET", "/api/v1/cast-codes", temp.path(), None)?; + assert_eq!(response.status, 200); + assert_eq!(response.content_type, "application/json"); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + let codes = body.as_array().expect("cast-codes returns an array"); + let literals: Vec<&str> = codes + .iter() + .map(|c| c["code"].as_str().expect("code is a string")) + .collect(); + assert!(literals.contains(&"~?")); + assert!(literals.contains(&"~>{familiar}")); + assert!(literals.contains(&"~delegate:{familiar}")); + assert!(literals.contains(&"~broadcast *")); + // No per-familiar literals — those are cockpit-side concerns once the + // daemon learns about specific familiars. + for code in &literals { + assert!( + !code.contains("sage") && !code.contains("cody"), + "unexpected per-familiar literal in /cast-codes: {code}" + ); } + let first = &codes[0]; + assert_eq!(first["type"], "status"); + Ok(()) + } - let limited = handle_request( - "GET", - "/events?sessionId=session-1&limit=2", - temp_dir.path(), + #[test] + fn delete_eval_loop_run_lock_clears_with_force() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let eval_dir = home.join("familiars").join("sage").join("eval-loop"); + std::fs::create_dir_all(&eval_dir)?; + std::fs::write(eval_dir.join("run.lock"), "run-123")?; + + let response = handle_request_with_body( + "DELETE", + "/api/v1/skills/eval-loop/sage/run-lock", + home, None, + Some(r#"{"force":true}"#), )?; - let body: serde_json::Value = serde_json::from_str(&limited.body)?; - assert_eq!(limited.status, 200); - assert_eq!(body["events"].as_array().unwrap().len(), 2); - assert_eq!(body["hasMore"], true); + + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["ok"], true); + assert_eq!(body["cleared"], true); + assert!(!eval_dir.join("run.lock").exists()); Ok(()) } #[test] - fn events_endpoint_combines_after_seq_cursor_with_limit() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; - - for data in &["a", "b", "c"] { - handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), - None, - Some(&format!(r#"{{"data":"{data}"}}"#)), - )?; - } - - let all = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; - let all_body: serde_json::Value = serde_json::from_str(&all.body)?; - let first_seq = all_body["events"][0]["seq"].as_i64().unwrap(); + fn delete_eval_loop_run_lock_rejects_fresh_lock_without_force() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let eval_dir = home.join("familiars").join("sage").join("eval-loop"); + std::fs::create_dir_all(&eval_dir)?; + std::fs::write(eval_dir.join("run.json"), r#"{"runId":"run-123"}"#)?; + std::fs::write(eval_dir.join("run.lock"), "run-123")?; - let page = handle_request( - "GET", - &format!("/events?sessionId=session-1&afterSeq={first_seq}&limit=1"), - temp_dir.path(), + let response = handle_request_with_body( + "DELETE", + "/api/v1/skills/eval-loop/sage/run-lock", + home, None, + Some(r#"{"force":false}"#), )?; - let body: serde_json::Value = serde_json::from_str(&page.body)?; - assert_eq!(page.status, 200); - assert_eq!(body["events"].as_array().unwrap().len(), 1); - assert!(body["events"][0]["seq"].as_i64().unwrap() > first_seq); - assert_eq!(body["nextCursor"]["afterSeq"], body["events"][0]["seq"]); - assert_eq!(body["hasMore"], true); + assert_eq!(response.status, 409); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "lock_not_stale"); + assert!(eval_dir.join("run.lock").exists()); Ok(()) } - #[test] - fn events_endpoint_supports_after_event_id_cursor() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; + fn fake_openai_key() -> String { + format!("sk-{}", "c".repeat(40)) + } - for data in &["a", "b", "c"] { - handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), - None, - Some(&format!(r#"{{"data":"{data}"}}"#)), - )?; - } + // ---- PUT /api/v1/familiars/{id}/icon --------------------------------- - let all = handle_request("GET", "/events?sessionId=session-1", temp_dir.path(), None)?; - let all_body: serde_json::Value = serde_json::from_str(&all.body)?; - let first_event_id = all_body["events"][0]["id"].as_str().unwrap(); - let second_event_id = all_body["events"][1]["id"].as_str().unwrap(); - let third_event_id = all_body["events"][2]["id"].as_str().unwrap(); + fn seed_familiars_toml(home: &Path) -> Result<()> { + std::fs::write( + home.join("familiars.toml"), + r#"[[familiar]] +id = "cody" +display_name = "Cody" +role = "Code" +description = "Builds and debugs." - let after = handle_request( - "GET", - &format!("/events?sessionId=session-1&afterEventId={first_event_id}"), - temp_dir.path(), - None, +[[familiar]] +id = "sage" +display_name = "Sage" +role = "Research" +description = "Reads and synthesizes." +icon = "ph:leaf-fill" +"#, )?; - let after_body: serde_json::Value = serde_json::from_str(&after.body)?; - assert_eq!(after.status, 200); - assert_eq!(after_body["events"].as_array().unwrap().len(), 2); - assert_eq!(after_body["events"][0]["id"], second_event_id); - assert_eq!(after_body["events"][1]["id"], third_event_id); Ok(()) } #[test] - fn events_endpoint_clamps_zero_limit_to_one_event() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; + fn familiar_ward_route_reports_declared_surface() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + seed_familiars_toml(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::create_dir_all(&workspace)?; + std::fs::write( + workspace.join("ward.toml"), + r#"principal_key_fingerprint = "SHA256:principal-key" +protected_surface = ["SOUL.md"] - for data in &["a", "b"] { - handle_request_with_body( - "POST", - "/sessions/session-1/input", - temp_dir.path(), - None, - Some(&format!(r#"{{"data":"{data}"}}"#)), - )?; - } +[[surface]] +path = "SOUL.md" +tier = 0 - let response = handle_request( - "GET", - "/events?sessionId=session-1&limit=0", - temp_dir.path(), - None, +[[surface]] +path = "memory/" +tier = 2 +"#, )?; - let body: serde_json::Value = serde_json::from_str(&response.body)?; + let response = handle_request("GET", "/api/v1/familiars/sage/ward", home, None)?; assert_eq!(response.status, 200); - assert_eq!(body["events"].as_array().unwrap().len(), 1); - assert_eq!(body["hasMore"], true); - Ok(()) - } - - #[test] - fn events_endpoint_returns_structured_error_for_missing_session_id() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - let response = handle_request("GET", "/events", temp_dir.path(), None)?; - - assert_eq!(response.status, 400); - assert!(response.body.contains(r#""code":"invalid_request""#)); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["ok"], true); + assert_eq!(body["familiarId"], "sage"); + assert_eq!( + body["ward"]["principalKeyFingerprint"], + "SHA256:principal-key" + ); + assert_eq!(body["ward"]["defaultTier"], 2); + assert_eq!(body["ward"]["surface"][0]["path"], "SOUL.md"); + assert_eq!(body["ward"]["surface"][0]["tier"], 0); + assert_eq!(body["ward"]["surface"][1]["path"], "memory/"); + assert_eq!(body["ward"]["surface"][1]["tier"], 2); + assert_eq!(body["ward"]["protectedSurface"][0], "SOUL.md"); Ok(()) } #[test] - fn events_endpoint_returns_structured_error_for_non_integer_limit() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; + fn familiar_ward_route_fails_closed_on_unknown_and_unconfigured() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + seed_familiars_toml(home)?; - let response = handle_request( - "GET", - "/events?sessionId=session-1&limit=foo", - temp_dir.path(), - None, - )?; + // Unknown familiar id. + let response = handle_request("GET", "/api/v1/familiars/ghost/ward", home, None)?; + assert_eq!(response.status, 404); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "familiar_not_found"); - assert_eq!(response.status, 400); - assert!(response.body.contains(r#""code":"invalid_request""#)); - assert!(response.body.contains(r#""limit":"foo""#)); + // Known familiar without a ward.toml. + let response = handle_request("GET", "/api/v1/familiars/sage/ward", home, None)?; + assert_eq!(response.status, 404); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "ward_not_configured"); + + // Malformed ids are a 400, matching the /icon and /edits contract. + for path in ["/api/v1/familiars//ward", "/api/v1/familiars/a/b/ward"] { + let response = handle_request("GET", path, home, None)?; + assert_eq!(response.status, 400, "path {path}"); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "invalid_request"); + } Ok(()) } #[test] - fn events_endpoint_returns_structured_error_for_non_integer_after_seq() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - insert_test_session(temp_dir.path(), "session-1")?; + fn threads_proposals_lists_staged_coherence_proposal() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + seed_warded_familiar(home)?; - let response = handle_request( - "GET", - "/events?sessionId=session-1&afterSeq=foo", - temp_dir.path(), - None, + // Empty state first: missing pending/ is an empty list, not an error. + let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["proposals"], serde_json::json!([])); + + // Stage a Tier-1 coherence proposal through the write path. + let staged = post_edits( + home, + r#"{"edits":[{"target":"reviewed/skill.md","contents":"tweak"}]}"#, )?; + let staged_body: serde_json::Value = serde_json::from_str(&staged.body)?; + let proposal_id = staged_body["proposalId"].as_str().expect("id").to_string(); - assert_eq!(response.status, 400); - assert!(response.body.contains(r#""code":"invalid_request""#)); - assert!(response.body.contains(r#""afterSeq":"foo""#)); - Ok(()) - } + // The list surfaces it with lane, familiar, and targets. + let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + let body: serde_json::Value = serde_json::from_str(&response.body)?; + let listed = &body["proposals"][0]; + assert_eq!(listed["proposalId"], proposal_id.as_str()); + assert_eq!(listed["familiarId"], "sage"); + assert_eq!(listed["reviewKind"], "coherence"); + assert_eq!(listed["targets"][0], "reviewed/skill.md"); - #[test] - fn events_endpoint_validates_limit_before_session_lookup() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; + // Detail returns the same record; unknown and malformed ids fail + // closed with the structured shapes. let response = handle_request( "GET", - "/events?sessionId=ghost&limit=foo", - temp_dir.path(), + &format!("/api/v1/threads/proposals/{proposal_id}"), + home, None, )?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["proposal"]["reviewKind"], "coherence"); - assert_eq!(response.status, 400); - assert!(response.body.contains(r#""code":"invalid_request""#)); - assert!(response.body.contains(r#""limit":"foo""#)); - Ok(()) - } - - #[test] - fn events_endpoint_validates_after_seq_before_session_lookup() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; let response = handle_request( "GET", - "/events?sessionId=ghost&afterSeq=foo", - temp_dir.path(), + "/api/v1/threads/proposals/00000000-0000-0000-0000-000000000000", + home, None, )?; + assert_eq!(response.status, 404); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "proposal_not_found"); + let response = handle_request("GET", "/api/v1/threads/proposals/not-a-uuid", home, None)?; assert_eq!(response.status, 400); - assert!(response.body.contains(r#""code":"invalid_request""#)); - assert!(response.body.contains(r#""afterSeq":"foo""#)); - Ok(()) - } - - #[test] - fn events_endpoint_returns_structured_error_for_unknown_session() -> anyhow::Result<()> { - let temp_dir = tempfile::tempdir()?; - let response = handle_request("GET", "/events?sessionId=ghost", temp_dir.path(), None)?; - - assert_eq!(response.status, 404); - assert!(response.body.contains(r#""code":"session_not_found""#)); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "invalid_request"); Ok(()) } #[test] - fn get_session_log_returns_log_lines_from_events() -> Result<()> { + fn threads_proposals_renders_validated_phase5_scheduler_state() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let conn = store::open_store(&store_path(home))?; - let session = store::SessionRecord { - id: "sess-log".into(), - project_root: "/tmp/proj".into(), - harness: "claude".into(), - title: "demo".into(), - status: "running".into(), - exit_code: None, - archived_at: None, - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-01-01T00:00:00Z".into(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, + seed_warded_familiar(home)?; + let proposal_id = coven_threads_core::ProposalId::new(); + let familiar_id = crate::threads_gate::familiar_weave_id("sage"); + let surface = coven_threads_core::SurfaceId::new("TOOLS.md"); + let staged_at = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)?; + let pending = coven_threads_core::PendingProposal { + id: proposal_id, + familiar_id, + writer: coven_threads_core::WriterId::new("principal:fpr-val"), + channel: coven_threads_core::Channel::Mutation, + thread_id: coven_threads_core::ThreadId::new(), + fray: coven_threads_core::FrayOrSnap::Frayed { + strand: None, + channel: coven_threads_core::Channel::Mutation, + reason: coven_threads_core::FrayReason::Other("phase-5 fixture".to_string()), + }, + edits: vec![coven_threads_core::StagedEdit { + surface: surface.clone(), + contents: coven_threads_core::StagedContents::from_bytes(b"tweak"), + }], + staged_at, }; - store::insert_session(&conn, &session)?; - insert_event(&conn, home, "sess-log", "input", json!({"text": "hello"}))?; - insert_event(&conn, home, "sess-log", "output", json!({"text": "world"}))?; - insert_event(&conn, home, "sess-log", "error", json!({"message": "boom"}))?; - drop(conn); + let diff = + coven_threads_core::MaterializedDiff::try_new(vec![coven_threads_core::SurfaceDiff { + surface: surface.clone(), + before: None, + after: Some(b"tweak".to_vec()), + }]) + .map_err(anyhow::Error::msg)?; + let evidence = + coven_threads_core::SurfaceRegionRegistry::default_registry().classify_all(&diff); + let classification = coven_threads_core::ProposalClassification { + proposal_id, + familiar_id, + channel: coven_threads_core::Channel::Mutation, + affected_surfaces: vec![surface], + affected_regions: evidence.iter().map(|item| item.region_id.clone()).collect(), + path_tier_floor: 1, + approval_path: coven_threads_core::ApprovalPath::FamiliarCoherence { + veto: coven_threads_core::VetoWindow::new( + std::time::Duration::from_secs(300), + std::time::Duration::from_secs(60), + ), + }, + evidence_replay_hash: coven_threads_core::evidence_replay_hash(&diff, &evidence), + classified_at: staged_at, + }; + let scheduled = + crate::proposal_scheduler::ScheduledProposal::try_new(pending, classification, diff)?; + let pending_dir = home.join("pending"); + std::fs::create_dir_all(&pending_dir)?; + std::fs::write( + pending_dir.join(format!("{familiar_id}-{proposal_id}.json")), + serde_json::to_vec_pretty(&scheduled)?, + )?; - let response = handle_request("GET", "/api/v1/sessions/sess-log/log", home, None)?; - assert_eq!(response.status, 200); + let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + + assert_eq!(response.status, 200, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - let lines = body.as_array().expect("array body"); - assert_eq!(lines.len(), 3); - assert_eq!(lines[0]["level"], "info"); - assert!(lines[0]["message"].as_str().unwrap().contains("hello")); - assert_eq!(lines[2]["level"], "error"); - assert!(lines[2]["message"].as_str().unwrap().contains("boom")); + let listed = &body["proposals"][0]; + assert_eq!(listed["proposalId"], proposal_id.to_string()); + assert_eq!(listed["familiarId"], "sage"); + assert_eq!(listed["approvalPath"]["variant"], "familiar_coherence"); + assert_eq!(listed["approvalPath"]["label"], "familiar_review"); + assert_eq!( + listed["approvalPath"]["veto_deadline"], + "2023-11-14T22:18:20Z" + ); + assert_eq!(listed["lifecycle"], "veto_window_open"); + assert_eq!(listed["earliestClose"], "2023-11-14T22:14:20Z"); + assert_eq!(listed["affectedRegions"][0], "tool_defaults"); + assert!(listed.get("reviewKind").is_none()); Ok(()) } #[test] - fn logs_and_events_return_redacted_payloads_by_default() -> Result<()> { + fn threads_proposals_does_not_fallback_malformed_phase5_to_legacy() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let conn = store::open_store(&store_path(home))?; - let session = store::SessionRecord { - id: "sess-secret".into(), - project_root: "/tmp/proj".into(), - harness: "codex".into(), - title: "demo".into(), - status: "running".into(), - exit_code: None, - archived_at: None, - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-01-01T00:00:00Z".into(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }; - store::insert_session(&conn, &session)?; - let fake = fake_openai_key(); - insert_event( - &conn, - home, - "sess-secret", - "input", - json!({"data": format!("Authorization: Bearer {fake}")}), - )?; - drop(conn); + let (pending_path, _) = stage_pending_protected_edit(home)?; + let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&pending_path)?)?; + value["classification"] = serde_json::json!({}); + std::fs::write(&pending_path, serde_json::to_vec_pretty(&value)?)?; - let log = handle_request("GET", "/api/v1/sessions/sess-secret/log", home, None)?; - let events = handle_request("GET", "/api/v1/events?sessionId=sess-secret", home, None)?; - let alias = handle_request("GET", "/api/v1/sessions/sess-secret/events", home, None)?; + let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; - for response in [log, events, alias] { - assert_eq!(response.status, 200); - assert!(!response.body.contains(&fake)); - assert!(response.body.contains("[REDACTED]")); - } + assert_eq!(response.status, 200, "got {}", response.body); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!( + body["proposals"][0]["degraded"]["reason"], + "proposal-unparseable" + ); + assert!(body["proposals"][0].get("proposalId").is_none()); Ok(()) } #[test] - fn raw_artifact_endpoint_is_disabled_by_default() -> Result<()> { + fn put_familiar_icon_updates_existing_value() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let conn = store::open_store(&store_path(home))?; - let session = store::SessionRecord { - id: "sess-secret".into(), - project_root: "/tmp/proj".into(), - harness: "codex".into(), - title: "demo".into(), - status: "running".into(), - exit_code: None, - archived_at: None, - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-01-01T00:00:00Z".into(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }; - store::insert_session(&conn, &session)?; - drop(conn); - - let response = handle_request( - "GET", - "/api/v1/sessions/sess-secret/artifacts/event-1?raw=1", + seed_familiars_toml(home)?; + let response = handle_request_with_body( + "PUT", + "/api/v1/familiars/sage/icon", home, None, + Some(r#"{"icon":"🌿"}"#), )?; - - assert_eq!(response.status, 403); - assert!(response.body.contains(r#""code":"raw_artifacts_disabled""#)); - assert!(!response.body.contains("payload")); + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["ok"], true); + assert_eq!(body["action"], "updated"); + assert_eq!(body["id"], "sage"); + let raw = std::fs::read_to_string(home.join("familiars.toml"))?; + assert!(raw.contains("icon = \"🌿\""), "got {raw}"); Ok(()) } #[test] - fn raw_artifact_endpoint_returns_404_for_expired_artifact() -> Result<()> { + fn put_familiar_icon_inserts_when_absent() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - std::fs::write(home.join("privacy.toml"), "persist_raw_artifacts = true\n")?; - let conn = store::open_store(&store_path(home))?; - let session = store::SessionRecord { - id: "sess-secret".into(), - project_root: "/tmp/proj".into(), - harness: "codex".into(), - title: "demo".into(), - status: "running".into(), - exit_code: None, - archived_at: None, - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-01-01T00:00:00Z".into(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }; - store::insert_session(&conn, &session)?; - insert_event( - &conn, - home, - "sess-secret", - "input", - json!({"data": "secret"}), - )?; - let event_id = store::list_events(&conn, "sess-secret")? - .pop() - .expect("event") - .id; - store::insert_sensitive_artifact( - &conn, - &store::SensitiveArtifactRecord { - id: "artifact-1".into(), - session_id: "sess-secret".into(), - event_id, - kind: "input".into(), - nonce: vec![0; 24], - ciphertext: vec![1, 2, 3], - created_at: "2026-01-01T00:00:00Z".into(), - expires_at: "2026-01-02T00:00:00Z".into(), - }, - )?; - drop(conn); - - let response = handle_request( - "GET", - "/api/v1/sessions/sess-secret/artifacts/artifact-1?raw=1", + seed_familiars_toml(home)?; + let response = handle_request_with_body( + "PUT", + "/api/v1/familiars/cody/icon", home, None, + Some(r#"{"icon":"ph:lightning-fill"}"#), )?; - - assert_eq!(response.status, 404); - assert!(response.body.contains(r#""code":"artifact_expired""#)); - assert!(!response.body.contains("payload")); + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["action"], "updated"); + let raw = std::fs::read_to_string(home.join("familiars.toml"))?; + assert!(raw.contains("icon = \"ph:lightning-fill\""), "got {raw}"); + // Other familiar's icon must be untouched. + assert!(raw.contains("icon = \"ph:leaf-fill\"")); Ok(()) } #[test] - fn get_session_log_returns_404_for_unknown_session() -> Result<()> { + fn put_familiar_icon_clears_when_null() -> Result<()> { let temp = tempfile::tempdir()?; - let response = handle_request("GET", "/api/v1/sessions/missing/log", temp.path(), None)?; - assert_eq!(response.status, 404); - assert!( - response.body.contains(r#""sessionId":"missing""#), - "expected sessionId 'missing' (not 'missing/log'); got: {}", - response.body - ); + let home = temp.path(); + seed_familiars_toml(home)?; + let response = handle_request_with_body( + "PUT", + "/api/v1/familiars/sage/icon", + home, + None, + Some(r#"{"icon":null}"#), + )?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["action"], "cleared"); + let raw = std::fs::read_to_string(home.join("familiars.toml"))?; + assert!(!raw.contains("ph:leaf-fill")); Ok(()) } #[test] - fn post_cast_records_event_and_returns_result() -> Result<()> { + fn put_familiar_icon_returns_404_for_unknown_id() -> Result<()> { let temp = tempfile::tempdir()?; - let body = json!({ - "code": "/status", - "target": null, - }); + let home = temp.path(); + seed_familiars_toml(home)?; let response = handle_request_with_body( - "POST", - "/api/v1/cast", - temp.path(), + "PUT", + "/api/v1/familiars/ghost/icon", + home, None, - Some(&body.to_string()), + Some(r#"{"icon":"ph:ghost-fill"}"#), )?; - assert_eq!(response.status, 202); - let result: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(result["accepted"], true); - assert!(result["cast_id"] - .as_str() - .expect("cast_id") - .starts_with("cast-")); - assert_eq!(result["echo"], "/status"); + assert_eq!(response.status, 404); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "familiar_not_found"); Ok(()) } #[test] - fn post_cast_rejects_missing_code() -> Result<()> { + fn put_familiar_icon_rejects_non_string_icon() -> Result<()> { let temp = tempfile::tempdir()?; - let body = json!({ "target": "sess-1" }); + let home = temp.path(); + seed_familiars_toml(home)?; let response = handle_request_with_body( - "POST", - "/api/v1/cast", - temp.path(), + "PUT", + "/api/v1/familiars/sage/icon", + home, None, - Some(&body.to_string()), + Some(r#"{"icon":[1,2,3]}"#), )?; assert_eq!(response.status, 400); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "invalid_request"); + // File must be untouched. + let raw = std::fs::read_to_string(home.join("familiars.toml"))?; + assert!(raw.contains("ph:leaf-fill")); Ok(()) } #[test] - fn post_cast_with_target_logs_event_to_session() -> Result<()> { + fn put_familiar_icon_with_empty_body_clears() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); + seed_familiars_toml(home)?; + let response = + handle_request_with_body("PUT", "/api/v1/familiars/sage/icon", home, None, None)?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["action"], "cleared"); + Ok(()) + } - let conn = store::open_store(&store_path(home))?; - let session = store::SessionRecord { - id: "sess-target".into(), - project_root: "/tmp/proj".into(), - harness: "claude".into(), - title: "demo".into(), - status: "running".into(), - exit_code: None, - archived_at: None, - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-01-01T00:00:00Z".into(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }; - store::insert_session(&conn, &session)?; - drop(conn); + // ---- POST /api/v1/familiars/{id}/edits (Ward write path) ------------- - let body = json!({ "code": "/handoff", "target": "sess-target" }); - let response = - handle_request_with_body("POST", "/api/v1/cast", home, None, Some(&body.to_string()))?; - assert_eq!(response.status, 202); - let result: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(result["accepted"], true); - assert_eq!(result["echo"], "/handoff → sess-target"); + /// Seed a warded sage: familiars.toml plus a workspace carrying a + /// ward.toml with SOUL.md protected (tier 0), reviewed/ tier 1, and the + /// default tier 2 everywhere else. Returns the workspace path. + fn seed_warded_familiar(home: &Path) -> Result { + seed_familiars_toml(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::create_dir_all(&workspace)?; + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + std::fs::write( + workspace.join("ward.toml"), + r#"principal_key_fingerprint = "fpr-val" +protected_surface = ["SOUL.md"] - // Verify the cast landed as an event on the target session, not __cockpit__. - let log_response = handle_request("GET", "/api/v1/sessions/sess-target/log", home, None)?; - assert_eq!(log_response.status, 200); - let lines: serde_json::Value = serde_json::from_str(&log_response.body)?; - let arr = lines.as_array().expect("array body"); - assert_eq!(arr.len(), 1); - assert!( - arr[0]["message"].as_str().unwrap().contains("/handoff"), - "expected log message to contain code; got: {}", - arr[0]["message"] - ); - Ok(()) +[[surface]] +path = "SOUL.md" +tier = 0 + +[[surface]] +path = "reviewed/" +tier = 1 +"#, + )?; + Ok(workspace) } - #[test] - fn post_cast_with_unknown_target_returns_404() -> Result<()> { - let temp = tempfile::tempdir()?; - let body = json!({ "code": "/status", "target": "no-such-session" }); - let response = handle_request_with_body( + fn post_edits(home: &Path, body: &str) -> Result { + handle_request_with_body( "POST", - "/api/v1/cast", - temp.path(), + "/api/v1/familiars/sage/edits", + home, None, - Some(&body.to_string()), - )?; - assert_eq!(response.status, 404); - assert!( - response.body.contains(r#""code":"session_not_found""#), - "expected session_not_found body; got: {}", - response.body - ); - assert!( - response.body.contains(r#""sessionId":"no-such-session""#), - "expected sessionId in error details; got: {}", - response.body - ); - Ok(()) + Some(body), + ) } #[test] - fn post_cast_without_target_idempotently_uses_cockpit_session() -> Result<()> { + fn post_familiar_edits_applies_and_audits_tier2_write() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let body = json!({ "code": "/status" }); - for _ in 0..3 { - let response = handle_request_with_body( - "POST", - "/api/v1/cast", - home, - None, - Some(&body.to_string()), - )?; - assert_eq!(response.status, 202); - } - // Only one __cockpit__ row should exist; all three casts land as events on it. - let conn = store::open_store(&store_path(home))?; - let sessions = store::list_sessions(&conn)?; - let cockpit_count = sessions.iter().filter(|s| s.id == "__cockpit__").count(); + let workspace = seed_warded_familiar(home)?; + + let response = post_edits( + home, + r#"{"edits":[{"target":"notes/today.md","contents":"hello ward"}]}"#, + )?; + + assert_eq!(response.status, 200, "got {}", response.body); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["ok"], true); + assert_eq!(body["disposition"], "applied"); + assert_eq!(body["changes"][0]["tier"], 2); + assert_eq!(body["changes"][0]["disposition"], "applied"); + // Gate 4: a tier-2 write must carry a tamper-evident audit record. + assert!( + body["changes"][0]["audit"]["nextSha256"].is_string(), + "expected audit record, got {}", + body["changes"][0] + ); assert_eq!( - cockpit_count, 1, - "expected exactly one __cockpit__ session row" + std::fs::read_to_string(workspace.join("notes/today.md"))?, + "hello ward" ); Ok(()) } #[test] - fn get_overview_returns_session_count_and_zeroed_unknowns() -> Result<()> { + fn post_familiar_edits_refuses_traversal_and_writes_nothing() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let conn = store::open_store(&store_path(home))?; - for id in ["s1", "s2", "s3"] { - let now = "2026-01-01T00:00:00Z"; - let status = if id == "s3" { "ended" } else { "running" }; - store::insert_session( - &conn, - &store::SessionRecord { - id: id.into(), - project_root: "/tmp".into(), - harness: "claude".into(), - title: "t".into(), - status: status.into(), - exit_code: None, - archived_at: None, - created_at: now.into(), - updated_at: now.into(), - conversation_id: None, - familiar_id: None, - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }, - )?; - } - drop(conn); + let workspace = seed_warded_familiar(home)?; - let response = handle_request("GET", "/api/v1/overview", home, None)?; - assert_eq!(response.status, 200); + // All-or-nothing: a clean tier-2 edit bundled with a traversal escape + // must not be written either. + let response = post_edits( + home, + r#"{"edits":[ + {"target":"notes/ok.md","contents":"fine"}, + {"target":"../escape.md","contents":"nope"} + ]}"#, + )?; + + assert_eq!(response.status, 403, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["open_sessions"], 2); - assert_eq!(body["active_familiars"], 0); - assert_eq!(body["skills_count"], 0); + assert_eq!(body["error"]["code"], "ward_refused"); + assert!(!workspace.join("notes/ok.md").exists()); + assert!(!home.join("familiars/escape.md").exists()); Ok(()) } #[test] - fn get_overview_counts_familiars_skills_and_research_from_local_sources() -> Result<()> { + fn post_familiar_edits_refuses_unsigned_protected_write() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); + let workspace = seed_warded_familiar(home)?; - std::fs::write( - home.join("familiars.toml"), - r#" -[[familiar]] -id = "charm" -display_name = "Charm" -role = "steward" -description = "keeps the hearth" - -[[familiar]] -id = "sage" -display_name = "Sage" -role = "researcher" -description = "digs deep" -"#, - )?; - - for skill in ["eval-loop", "stream-scribe"] { - let dir = home.join("skills").join(skill); - std::fs::create_dir_all(&dir)?; - std::fs::write( - dir.join("metadata.json"), - format!(r#"{{"name":"{skill}","description":"a skill","version":"1.0.0"}}"#), - )?; - } - - let research_dir = home.join("research"); - std::fs::create_dir_all(&research_dir)?; - std::fs::write( - research_dir.join("results.tsv"), - "1\tharness capabilities\t7.5\t1.5\tcontinue\tnotes.md\n\ - 2\tstream continuity\t9.0\t2.0\tadopt\tnotes.md\n", + let response = post_edits( + home, + r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}]}"#, )?; - let conn = store::open_store(&store_path(home))?; - let now = "2026-01-01T00:00:00Z"; - for (id, status, familiar) in [ - ("s1", "running", Some("charm")), - ("s2", "running", Some("charm")), - ("s3", "running", Some("ghost-not-in-roster")), - ("s4", "ended", Some("sage")), - ] { - store::insert_session( - &conn, - &store::SessionRecord { - id: id.into(), - project_root: "/tmp".into(), - harness: "claude".into(), - title: "t".into(), - status: status.into(), - exit_code: None, - archived_at: None, - created_at: now.into(), - updated_at: now.into(), - conversation_id: None, - familiar_id: familiar.map(str::to_string), - labels: Vec::new(), - visibility: "private".to_string(), - external: false, - transcript_path: None, - }, - )?; - } - drop(conn); - - let response = handle_request("GET", "/api/v1/overview", home, None)?; - assert_eq!(response.status, 200); + assert_eq!(response.status, 403, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["open_sessions"], 3); - assert_eq!(body["total_familiars"], 2); - // Only roster familiars with an open session count as active: charm - // (running twice, deduped); sage's session ended; the ghost id is not - // in the roster. - assert_eq!(body["active_familiars"], 1); - assert_eq!(body["skills_count"], 2); - // Skill scores are stubbed at 0.0 until scoring lands. - assert_eq!(body["average_skill_score"], 0); - assert_eq!(body["research_iterations"], 2); - assert_eq!(body["last_research_delta"], 2); + assert_eq!(body["error"]["code"], "ward_refused"); + assert_eq!( + body["error"]["details"]["changes"][0]["verdict"]["kind"], + "blocked" + ); + assert_eq!( + std::fs::read_to_string(workspace.join("SOUL.md"))?, + "# Sage\n" + ); Ok(()) } #[test] - fn empty_array_stubs_return_200_with_empty_json_array() -> Result<()> { + fn post_familiar_edits_holds_authorized_protected_write() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - for route in [ - "/api/v1/familiars", - "/api/v1/skills", - "/api/v1/memory", - "/api/v1/research", - ] { - let response = handle_request("GET", route, home, None)?; - assert_eq!(response.status, 200, "route {route}"); - assert_eq!(response.content_type, "application/json", "route {route}"); - assert_eq!(response.body, "[]", "route {route}"); - } + let workspace = seed_warded_familiar(home)?; + + // Gate 1 passes, but Gate 3 (coherence) is unimplemented: held, not + // written — fail-closed. + let response = post_edits( + home, + r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], + "principalKeyFingerprint":"fpr-val"}"#, + )?; + + assert_eq!(response.status, 202, "got {}", response.body); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["disposition"], "held"); + // An authorized-protected hold is the authority lane's business — + // it must NOT be staged into the Gate-3 coherence lane. + assert!(body.get("reviewKind").is_none()); + assert_eq!( + std::fs::read_to_string(workspace.join("SOUL.md"))?, + "# Sage\n" + ); + // The coven-threads gate ran first and permitted: the verdict is in + // the payload and in the append-only ward_audit ledger. + assert_eq!( + body["threadsGate"]["outcome"]["kind"], "permitted", + "got {}", + response.body + ); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let decision: String = conn.query_row( + "SELECT decision FROM ward_audit WHERE familiar_id='sage' ORDER BY id DESC LIMIT 1", + [], + |row| row.get(0), + )?; + assert_eq!(decision, "permit"); Ok(()) } #[test] - fn get_cast_codes_returns_grammar_templates() -> Result<()> { + fn post_familiar_edits_stages_to_pending_after_out_of_band_drift() -> Result<()> { + // §5 of the coven-threads design (DegradeToProposal), end to end: + // baseline the surface, drift it outside the daemon, then propose — + // the write is staged at ~/.coven/pending/, the surface is untouched, + // and `staged` is the one additive disposition the §6 compatibility + // contract allows. let temp = tempfile::tempdir()?; - let response = handle_request("GET", "/api/v1/cast-codes", temp.path(), None)?; - assert_eq!(response.status, 200); - assert_eq!(response.content_type, "application/json"); + let home = temp.path(); + let workspace = seed_warded_familiar(home)?; + + // First signed request bootstraps the content baseline (held). + let first = post_edits( + home, + r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], + "principalKeyFingerprint":"fpr-val"}"#, + )?; + assert_eq!(first.status, 202, "got {}", first.body); + + // Out-of-band drift: something edits SOUL.md around the daemon. + std::fs::write(workspace.join("SOUL.md"), "# Mallory\n")?; + + let response = post_edits( + home, + r#"{"edits":[{"target":"SOUL.md","contents":"new identity v2"}], + "principalKeyFingerprint":"fpr-val"}"#, + )?; + assert_eq!(response.status, 202, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - let codes = body.as_array().expect("cast-codes returns an array"); - let literals: Vec<&str> = codes - .iter() - .map(|c| c["code"].as_str().expect("code is a string")) - .collect(); - assert!(literals.contains(&"~?")); - assert!(literals.contains(&"~>{familiar}")); - assert!(literals.contains(&"~delegate:{familiar}")); - assert!(literals.contains(&"~broadcast *")); - // No per-familiar literals — those are cockpit-side concerns once the - // daemon learns about specific familiars. - for code in &literals { - assert!( - !code.contains("sage") && !code.contains("cody"), - "unexpected per-familiar literal in /cast-codes: {code}" - ); - } - let first = &codes[0]; - assert_eq!(first["type"], "status"); + assert_eq!(body["disposition"], "staged"); + assert_eq!(body["threadsGate"]["outcome"]["kind"], "staged"); + + let pending = body["threadsGate"]["outcome"]["pendingPath"] + .as_str() + .expect("staged outcome carries pendingPath"); + assert!( + std::path::Path::new(pending).exists(), + "pending proposal file must exist" + ); + // The staged proposal carries the full desired contents. + let staged: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(pending)?)?; + assert_eq!(staged["edits"][0]["surface"], "SOUL.md"); + // Nothing wrote the protected surface. + assert_eq!( + std::fs::read_to_string(workspace.join("SOUL.md"))?, + "# Mallory\n" + ); + // The degrade decision is in the append-only ledger. + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let decision: String = conn.query_row( + "SELECT decision FROM ward_audit WHERE familiar_id='sage' ORDER BY id DESC LIMIT 1", + [], + |row| row.get(0), + )?; + assert_eq!(decision, "degrade_to_proposal"); Ok(()) } #[test] - fn delete_eval_loop_run_lock_clears_with_force() -> Result<()> { + fn post_familiar_edits_blocked_target_refuses_even_with_drifted_surface() -> Result<()> { + // Review finding: a mixed proposal (drifted Tier-0 edit + traversal + // escape) must be refused as a unit BEFORE the threads gate can stage + // it — a blocked target must never ride into ~/.coven/pending/. let temp = tempfile::tempdir()?; let home = temp.path(); - let eval_dir = home.join("familiars").join("sage").join("eval-loop"); - std::fs::create_dir_all(&eval_dir)?; - std::fs::write(eval_dir.join("run.lock"), "run-123")?; + let workspace = seed_warded_familiar(home)?; - let response = handle_request_with_body( - "DELETE", - "/api/v1/skills/eval-loop/sage/run-lock", + // Baseline SOUL.md, then drift it so the gate would want to stage. + let first = post_edits( home, - None, - Some(r#"{"force":true}"#), + r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], + "principalKeyFingerprint":"fpr-val"}"#, )?; + assert_eq!(first.status, 202, "got {}", first.body); + std::fs::write(workspace.join("SOUL.md"), "# Mallory\n")?; - assert_eq!(response.status, 200); + let response = post_edits( + home, + r#"{"edits":[ + {"target":"SOUL.md","contents":"new identity v2"}, + {"target":"../escape.md","contents":"nope"} + ], "principalKeyFingerprint":"fpr-val"}"#, + )?; + assert_eq!(response.status, 403, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["ok"], true); - assert_eq!(body["cleared"], true); - assert!(!eval_dir.join("run.lock").exists()); + assert_eq!(body["error"]["code"], "ward_refused"); + // Nothing was staged and nothing escaped. + let pending = home.join("pending"); + let staged_count = std::fs::read_dir(&pending) + .map(|entries| entries.count()) + .unwrap_or(0); + assert_eq!(staged_count, 0, "blocked proposal must not stage"); + assert!(!home.join("familiars/escape.md").exists()); Ok(()) } #[test] - fn delete_eval_loop_run_lock_rejects_fresh_lock_without_force() -> Result<()> { + fn post_familiar_edits_editable_tier_bypasses_the_weave() -> Result<()> { + // Editable-tier writes are the Ward tiers' lane: the weave reports no + // verdicts and appends nothing to ward_audit. let temp = tempfile::tempdir()?; let home = temp.path(); - let eval_dir = home.join("familiars").join("sage").join("eval-loop"); - std::fs::create_dir_all(&eval_dir)?; - std::fs::write(eval_dir.join("run.json"), r#"{"runId":"run-123"}"#)?; - std::fs::write(eval_dir.join("run.lock"), "run-123")?; - - let response = handle_request_with_body( - "DELETE", - "/api/v1/skills/eval-loop/sage/run-lock", + seed_warded_familiar(home)?; + let response = post_edits( home, - None, - Some(r#"{"force":false}"#), + r#"{"edits":[{"target":"notes/today.md","contents":"hello ward"}]}"#, )?; - - assert_eq!(response.status, 409); + assert_eq!(response.status, 200, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "lock_not_stale"); - assert!(eval_dir.join("run.lock").exists()); + assert_eq!( + body["threadsGate"]["verdicts"].as_array().map(Vec::len), + Some(0) + ); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let count: i64 = conn.query_row("SELECT COUNT(*) FROM ward_audit", [], |row| row.get(0))?; + assert_eq!(count, 0); Ok(()) } - fn fake_openai_key() -> String { - format!("sk-{}", "c".repeat(40)) - } - - // ---- PUT /api/v1/familiars/{id}/icon --------------------------------- - - fn seed_familiars_toml(home: &Path) -> Result<()> { - std::fs::write( - home.join("familiars.toml"), - r#"[[familiar]] -id = "cody" -display_name = "Cody" -role = "Code" -description = "Builds and debugs." + #[test] + fn threads_weaves_returns_cave_normalizable_weave_entries() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + seed_warded_familiar(home)?; -[[familiar]] -id = "sage" -display_name = "Sage" -role = "Research" -description = "Reads and synthesizes." -icon = "ph:leaf-fill" -"#, + let baseline = post_edits( + home, + r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], + "principalKeyFingerprint":"fpr-val"}"#, )?; + assert_eq!(baseline.status, 202, "got {}", baseline.body); + + let response = handle_request("GET", "/api/v1/threads/weaves", home, None)?; + assert_eq!(response.status, 200, "got {}", response.body); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + let entries = body.as_array().expect("daemon returns a raw entry array"); + assert_eq!(entries.len(), 1); + let entry = &entries[0]; + assert_eq!(entry["coherence"], "Coherent"); + assert_eq!(entry["weave"]["familiar_id"], "sage"); + assert!(entry["weave"]["id"].is_string()); + assert!(entry["weave"]["weave_hash"] + .as_array() + .is_some_and(|v| !v.is_empty())); + assert_eq!(entry["weave"]["threads"][0]["surface"], "SOUL.md"); + assert_eq!(entry["weave"]["threads"][0]["writer"], "principal:fpr-val"); + assert_eq!(entry["weave"]["threads"][0]["tension"], "Holds"); + assert!(entry["weave"]["threads"][0]["created_at"].is_array()); + assert!(entry["weave"]["threads"][0]["strands"] + .as_array() + .is_some_and(|v| !v.is_empty())); + assert!(entry["weave"]["pattern_descriptor"]["name"].is_string()); Ok(()) } #[test] - fn familiar_ward_route_reports_declared_surface() -> Result<()> { + fn threads_weaves_skips_malformed_ward_without_aborting_fleet_read() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; - let workspace = home.join("familiars").join("sage"); - std::fs::create_dir_all(&workspace)?; + seed_warded_familiar(home)?; + + let cody_workspace = home.join("familiars").join("cody"); + std::fs::create_dir_all(&cody_workspace)?; + std::fs::write(cody_workspace.join("SOUL.md"), "# Cody\n")?; std::fs::write( - workspace.join("ward.toml"), - r#"principal_key_fingerprint = "SHA256:principal-key" -protected_surface = ["SOUL.md"] + cody_workspace.join("ward.toml"), + r#"protected_surface = ["SOUL.md"] [[surface]] path = "SOUL.md" tier = 0 - -[[surface]] -path = "memory/" -tier = 2 "#, )?; - let response = handle_request("GET", "/api/v1/familiars/sage/ward", home, None)?; - assert_eq!(response.status, 200); + let response = handle_request("GET", "/api/v1/threads/weaves", home, None)?; + + assert_eq!(response.status, 200, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["ok"], true); - assert_eq!(body["familiarId"], "sage"); + let entries = body.as_array().expect("weaves response is an array"); assert_eq!( - body["ward"]["principalKeyFingerprint"], - "SHA256:principal-key" + entries.len(), + 2, + "malformed cody ward must not abort healthy weave or vanish" + ); + let healthy = entries + .iter() + .find(|entry| entry.get("weave").is_some()) + .expect("healthy weave is still listed"); + assert_eq!(healthy["weave"]["familiar_id"], "sage"); + let degraded = entries + .iter() + .find_map(|entry| entry.get("degraded")) + .expect("malformed ward appears as a degraded familiar"); + assert_eq!(degraded["familiarId"], "cody"); + assert_eq!(degraded["reason"], "ward-config-unparseable"); + let error = degraded["error"].as_str().expect("error string"); + assert!( + !error.contains('\n'), + "error must be single-line: {error:?}" + ); + assert!( + error.contains("ward.toml"), + "error names ward.toml: {error}" + ); + assert!( + !error.contains(&home.display().to_string()), + "error must not leak absolute home path: {error}" + ); + assert!( + !error.contains("/familiars/cody/ward.toml"), + "error must not leak rooted ward path: {error}" ); - assert_eq!(body["ward"]["defaultTier"], 2); - assert_eq!(body["ward"]["surface"][0]["path"], "SOUL.md"); - assert_eq!(body["ward"]["surface"][0]["tier"], 0); - assert_eq!(body["ward"]["surface"][1]["path"], "memory/"); - assert_eq!(body["ward"]["surface"][1]["tier"], 2); - assert_eq!(body["ward"]["protectedSurface"][0], "SOUL.md"); Ok(()) } #[test] - fn familiar_ward_route_fails_closed_on_unknown_and_unconfigured() -> Result<()> { + fn threads_weaves_omits_familiars_without_ward_toml() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; + seed_warded_familiar(home)?; + let cody_workspace = home.join("familiars").join("cody"); + std::fs::create_dir_all(&cody_workspace)?; + std::fs::write(cody_workspace.join("SOUL.md"), "# Cody\n")?; - // Unknown familiar id. - let response = handle_request("GET", "/api/v1/familiars/ghost/ward", home, None)?; - assert_eq!(response.status, 404); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "familiar_not_found"); + let response = handle_request("GET", "/api/v1/threads/weaves", home, None)?; - // Known familiar without a ward.toml. - let response = handle_request("GET", "/api/v1/familiars/sage/ward", home, None)?; - assert_eq!(response.status, 404); + assert_eq!(response.status, 200, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "ward_not_configured"); - - // Malformed ids are a 400, matching the /icon and /edits contract. - for path in ["/api/v1/familiars//ward", "/api/v1/familiars/a/b/ward"] { - let response = handle_request("GET", path, home, None)?; - assert_eq!(response.status, 400, "path {path}"); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "invalid_request"); - } + let entries = body.as_array().expect("weaves response is an array"); + assert_eq!(entries.len(), 1, "no-ward familiar should be skipped"); + assert_eq!(entries[0]["weave"]["familiar_id"], "sage"); + assert!( + entries.iter().all(|entry| entry.get("degraded").is_none()), + "no-ward familiar is not degraded: {entries:?}" + ); Ok(()) } - #[test] - fn threads_proposals_lists_staged_coherence_proposal() -> Result<()> { - let temp = tempfile::tempdir()?; - let home = temp.path(); - seed_warded_familiar(home)?; - - // Empty state first: missing pending/ is an empty list, not an error. - let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["proposals"], serde_json::json!([])); - - // Stage a Tier-1 coherence proposal through the write path. - let staged = post_edits( + fn stage_pending_protected_edit(home: &Path) -> Result<(std::path::PathBuf, String)> { + let workspace = seed_warded_familiar(home)?; + let baseline = post_edits( home, - r#"{"edits":[{"target":"reviewed/skill.md","contents":"tweak"}]}"#, + r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], + "principalKeyFingerprint":"fpr-val"}"#, )?; - let staged_body: serde_json::Value = serde_json::from_str(&staged.body)?; - let proposal_id = staged_body["proposalId"].as_str().expect("id").to_string(); - - // The list surfaces it with lane, familiar, and targets. - let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; - let body: serde_json::Value = serde_json::from_str(&response.body)?; - let listed = &body["proposals"][0]; - assert_eq!(listed["proposalId"], proposal_id.as_str()); - assert_eq!(listed["familiarId"], "sage"); - assert_eq!(listed["reviewKind"], "coherence"); - assert_eq!(listed["targets"][0], "reviewed/skill.md"); - - // Detail returns the same record; unknown and malformed ids fail - // closed with the structured shapes. - let response = handle_request( - "GET", - &format!("/api/v1/threads/proposals/{proposal_id}"), + assert_eq!(baseline.status, 202, "got {}", baseline.body); + std::fs::write(workspace.join("SOUL.md"), "# Mallory\n")?; + let staged = post_edits( home, - None, + r#"{"edits":[{"target":"SOUL.md","contents":"approved identity"}], + "principalKeyFingerprint":"fpr-val"}"#, )?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["proposal"]["reviewKind"], "coherence"); + assert_eq!(staged.status, 202, "got {}", staged.body); + let body: serde_json::Value = serde_json::from_str(&staged.body)?; + let pending = std::path::PathBuf::from( + body["threadsGate"]["outcome"]["pendingPath"] + .as_str() + .expect("staged response carries pendingPath"), + ); + let proposal_id = body["threadsGate"]["outcome"]["proposalId"] + .as_str() + .expect("staged response carries proposalId") + .to_string(); + Ok((pending, proposal_id)) + } - let response = handle_request( - "GET", - "/api/v1/threads/proposals/00000000-0000-0000-0000-000000000000", + fn stage_scheduled_reviewed_edit( + home: &Path, + approval_path: coven_threads_core::ApprovalPath, + staged_at: time::OffsetDateTime, + ) -> Result<(std::path::PathBuf, String)> { + stage_scheduled_reviewed_edit_on_channel( home, - None, - )?; - assert_eq!(response.status, 404); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "proposal_not_found"); + approval_path, + staged_at, + coven_threads_core::Channel::Mutation, + ) + } - let response = handle_request("GET", "/api/v1/threads/proposals/not-a-uuid", home, None)?; - assert_eq!(response.status, 400); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "invalid_request"); - Ok(()) + fn stage_scheduled_reviewed_edit_on_channel( + home: &Path, + approval_path: coven_threads_core::ApprovalPath, + staged_at: time::OffsetDateTime, + channel: coven_threads_core::Channel, + ) -> Result<(std::path::PathBuf, String)> { + stage_scheduled_edit( + home, + "reviewed/skill.md", + 1, + approval_path, + staged_at, + channel, + ) } - #[test] - fn threads_proposals_renders_validated_phase5_scheduler_state() -> Result<()> { - let temp = tempfile::tempdir()?; - let home = temp.path(); - seed_warded_familiar(home)?; + fn stage_scheduled_edit( + home: &Path, + target: &str, + path_tier_floor: u8, + approval_path: coven_threads_core::ApprovalPath, + staged_at: time::OffsetDateTime, + channel: coven_threads_core::Channel, + ) -> Result<(std::path::PathBuf, String)> { + let workspace = seed_warded_familiar(home)?; + if let Some(parent) = workspace.join(target).parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(workspace.join(target), b"before")?; let proposal_id = coven_threads_core::ProposalId::new(); let familiar_id = crate::threads_gate::familiar_weave_id("sage"); - let surface = coven_threads_core::SurfaceId::new("TOOLS.md"); - let staged_at = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)?; + let surface = coven_threads_core::SurfaceId::new(target); let pending = coven_threads_core::PendingProposal { id: proposal_id, familiar_id, writer: coven_threads_core::WriterId::new("principal:fpr-val"), - channel: coven_threads_core::Channel::Mutation, + channel, thread_id: coven_threads_core::ThreadId::new(), fray: coven_threads_core::FrayOrSnap::Frayed { strand: None, - channel: coven_threads_core::Channel::Mutation, - reason: coven_threads_core::FrayReason::Other("phase-5 fixture".to_string()), + channel, + reason: coven_threads_core::FrayReason::Other("phase-5 decision".to_string()), }, edits: vec![coven_threads_core::StagedEdit { surface: surface.clone(), - contents: coven_threads_core::StagedContents::from_bytes(b"tweak"), + contents: coven_threads_core::StagedContents::from_bytes(b"after"), }], staged_at, }; let diff = coven_threads_core::MaterializedDiff::try_new(vec![coven_threads_core::SurfaceDiff { surface: surface.clone(), - before: None, - after: Some(b"tweak".to_vec()), + before: Some(b"before".to_vec()), + after: Some(b"after".to_vec()), }]) .map_err(anyhow::Error::msg)?; let evidence = @@ -6698,16 +8889,11 @@ tier = 2 let classification = coven_threads_core::ProposalClassification { proposal_id, familiar_id, - channel: coven_threads_core::Channel::Mutation, + channel, affected_surfaces: vec![surface], affected_regions: evidence.iter().map(|item| item.region_id.clone()).collect(), - path_tier_floor: 1, - approval_path: coven_threads_core::ApprovalPath::FamiliarCoherence { - veto: coven_threads_core::VetoWindow::new( - std::time::Duration::from_secs(300), - std::time::Duration::from_secs(60), - ), - }, + path_tier_floor, + approval_path, evidence_replay_hash: coven_threads_core::evidence_replay_hash(&diff, &evidence), classified_at: staged_at, }; @@ -6715,632 +8901,995 @@ tier = 2 crate::proposal_scheduler::ScheduledProposal::try_new(pending, classification, diff)?; let pending_dir = home.join("pending"); std::fs::create_dir_all(&pending_dir)?; - std::fs::write( - pending_dir.join(format!("{familiar_id}-{proposal_id}.json")), - serde_json::to_vec_pretty(&scheduled)?, + let path = pending_dir.join(format!("{familiar_id}-{proposal_id}.json")); + std::fs::write(&path, serde_json::to_vec_pretty(&scheduled)?)?; + Ok((path, proposal_id.to_string())) + } + + #[test] + fn threads_scheduled_human_required_enforces_rationale_and_applies() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (pending, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApprovalWithRationale, + time::OffsetDateTime::now_utc(), )?; - let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + let missing_rationale = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + )?; + assert_eq!( + missing_rationale.status, 409, + "got {}", + missing_rationale.body + ); + let body: Value = serde_json::from_str(&missing_rationale.body)?; + assert_eq!(body["why"], "proposal-rationale-required"); + assert!(pending.exists()); - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - let listed = &body["proposals"][0]; - assert_eq!(listed["proposalId"], proposal_id.to_string()); - assert_eq!(listed["familiarId"], "sage"); - assert_eq!(listed["approvalPath"]["variant"], "familiar_coherence"); - assert_eq!(listed["approvalPath"]["label"], "familiar_review"); + let approved = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some(r#"{"note":"reviewed semantic change"}"#), + )?; + assert_eq!(approved.status, 200, "got {}", approved.body); assert_eq!( - listed["approvalPath"]["veto_deadline"], - "2023-11-14T22:18:20Z" + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "after" ); - assert_eq!(listed["lifecycle"], "veto_window_open"); - assert_eq!(listed["earliestClose"], "2023-11-14T22:14:20Z"); - assert_eq!(listed["affectedRegions"][0], "tool_defaults"); - assert!(listed.get("reviewKind").is_none()); + assert!(!pending.exists()); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let detail: String = conn.query_row( + "SELECT detail FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_approved'", + [&proposal_id], + |row| row.get(0), + )?; + let detail: coven_threads_core::ProposalApprovalAuditDetail = + serde_json::from_str(&detail)?; + assert_eq!(detail.approval_path_label, "human_required"); + assert_eq!( + detail.rationale.as_deref(), + Some("reviewed semantic change") + ); + Ok(()) + } + + #[test] + fn threads_scheduled_audits_preserve_committed_channel() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (_, proposal_id) = stage_scheduled_reviewed_edit_on_channel( + home, + coven_threads_core::ApprovalPath::HumanApproval, + time::OffsetDateTime::now_utc(), + coven_threads_core::Channel::Serialization, + )?; + + let approved = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + )?; + assert_eq!(approved.status, 200, "got {}", approved.body); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let mismatched: i64 = conn.query_row( + "SELECT COUNT(*) FROM ward_audit + WHERE proposal_id = ?1 AND channel != 'serialization'", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!(mismatched, 0); + Ok(()) + } + + #[test] + fn scheduled_apply_intent_uses_committed_before_image() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (pending, _) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApproval, + time::OffsetDateTime::now_utc(), + )?; + let scheduled: crate::proposal_scheduler::ScheduledProposal = + serde_json::from_slice(&std::fs::read(pending)?)?; + let workspace = home.join("familiars/sage"); + std::fs::write(workspace.join("reviewed/skill.md"), b"concurrent")?; + + let before_images = proposal_before_images( + &workspace, + &["reviewed/skill.md".to_string()], + Some(&scheduled), + )?; + + assert_eq!( + before_images[0] + .contents + .to_bytes() + .map_err(anyhow::Error::msg)?, + b"before" + ); + Ok(()) + } + + #[test] + fn threads_scheduled_veto_window_delays_apply_and_records_veto() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let veto = coven_threads_core::VetoWindow::new( + std::time::Duration::from_secs(300), + std::time::Duration::from_secs(60), + ); + let (pending, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, + time::OffsetDateTime::now_utc(), + )?; + + let premature = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + )?; + assert_eq!(premature.status, 409, "got {}", premature.body); + let body: Value = serde_json::from_str(&premature.body)?; + assert_eq!(body["why"], "proposal-minimum-visibility-open"); + assert_eq!( + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "before" + ); + + let vetoed = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/reject"), + home, + None, + Some(r#"{"note":"familiar objected"}"#), + )?; + assert_eq!(vetoed.status, 200, "got {}", vetoed.body); + let body: Value = serde_json::from_str(&vetoed.body)?; + assert_eq!(body["decision"], "vetoed"); + assert!(!pending.exists()); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let (event, detail): (String, String) = conn.query_row( + "SELECT event_type, detail FROM ward_audit WHERE proposal_id = ?1", + [&proposal_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + assert_eq!(event, "proposal_vetoed"); + let close: coven_threads_core::ProposalWindowCloseAuditDetail = + serde_json::from_str(&detail)?; + assert_eq!(close.reason, coven_threads_core::WindowCloseReason::Vetoed); + assert_eq!(close.replay_hash_matched, None); + assert_eq!(close.rationale.as_deref(), Some("familiar objected")); Ok(()) } #[test] - fn threads_proposals_does_not_fallback_malformed_phase5_to_legacy() -> Result<()> { + fn threads_scheduled_applies_only_after_veto_deadline() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let (pending_path, _) = stage_pending_protected_edit(home)?; - let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&pending_path)?)?; - value["classification"] = serde_json::json!({}); - std::fs::write(&pending_path, serde_json::to_vec_pretty(&value)?)?; + let veto = coven_threads_core::VetoWindow::new( + std::time::Duration::from_secs(300), + std::time::Duration::from_secs(60), + ); + let (_, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, + time::OffsetDateTime::now_utc() - time::Duration::minutes(10), + )?; - let response = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + let approved = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + )?; - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(approved.status, 200, "got {}", approved.body); assert_eq!( - body["proposals"][0]["degraded"]["reason"], - "proposal-unparseable" + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "after" ); - assert!(body["proposals"][0].get("proposalId").is_none()); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let detail: String = conn.query_row( + "SELECT detail FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_approved'", + [&proposal_id], + |row| row.get(0), + )?; + let detail: coven_threads_core::ProposalApprovalAuditDetail = + serde_json::from_str(&detail)?; + assert_eq!(detail.approval_path_label, "familiar_review"); + let close = detail + .window_close + .expect("delayed apply records window close"); + assert_eq!(close.reason, coven_threads_core::WindowCloseReason::Applied); + assert_eq!(close.replay_hash_matched, Some(true)); Ok(()) } #[test] - fn put_familiar_icon_updates_existing_value() -> Result<()> { + fn threads_scheduled_deadline_replay_refuses_diverged_before_image() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; + let (pending, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApproval, + time::OffsetDateTime::now_utc(), + )?; + let target = home.join("familiars/sage/reviewed/skill.md"); + std::fs::write(&target, "concurrent")?; + let response = handle_request_with_body( - "PUT", - "/api/v1/familiars/sage/icon", + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"icon":"🌿"}"#), + Some("{}"), )?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["ok"], true); - assert_eq!(body["action"], "updated"); - assert_eq!(body["id"], "sage"); - let raw = std::fs::read_to_string(home.join("familiars.toml"))?; - assert!(raw.contains("icon = \"🌿\""), "got {raw}"); + + assert_eq!(response.status, 409, "got {}", response.body); + let body: Value = serde_json::from_str(&response.body)?; + assert_eq!(body["why"], "proposal-evidence-diverged"); + assert_eq!(std::fs::read_to_string(target)?, "concurrent"); + assert!(!pending.exists(), "failed deadline replay is terminal"); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let event: String = conn.query_row( + "SELECT event_type FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_rejected'", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!(event, "proposal_rejected"); Ok(()) } #[test] - fn put_familiar_icon_inserts_when_absent() -> Result<()> { + fn threads_scheduled_rejects_live_promotion_to_protected_tier() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; + let (pending, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApproval, + time::OffsetDateTime::now_utc(), + )?; + let ward_path = home.join("familiars/sage/ward.toml"); + let ward = std::fs::read_to_string(&ward_path)? + .replace( + "protected_surface = [\"SOUL.md\"]", + "protected_surface = [\"SOUL.md\", \"reviewed/skill.md\"]", + ) + .replace( + "path = \"reviewed/\"\ntier = 1", + "path = \"reviewed/skill.md\"\ntier = 0", + ); + std::fs::write(&ward_path, ward)?; + let response = handle_request_with_body( - "PUT", - "/api/v1/familiars/cody/icon", + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"icon":"ph:lightning-fill"}"#), + Some("{}"), )?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["action"], "updated"); - let raw = std::fs::read_to_string(home.join("familiars.toml"))?; - assert!(raw.contains("icon = \"ph:lightning-fill\""), "got {raw}"); - // Other familiar's icon must be untouched. - assert!(raw.contains("icon = \"ph:leaf-fill\"")); + + assert_eq!(response.status, 409, "got {}", response.body); + let body: Value = serde_json::from_str(&response.body)?; + assert_eq!(body["why"], "proposal-live-tier-escalated"); + assert!(!pending.exists()); + assert_eq!( + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "before" + ); Ok(()) } #[test] - fn put_familiar_icon_clears_when_null() -> Result<()> { + fn threads_scheduled_recovery_reparses_authority_envelope() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; - let response = handle_request_with_body( - "PUT", - "/api/v1/familiars/sage/icon", + let (_, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApprovalWithRationale, + time::OffsetDateTime::now_utc(), + )?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ApplyBeforeAudit, + proposal_id.clone(), + ))); + let interrupted = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"icon":null}"#), + Some(r#"{"note":"durable rationale"}"#), + ); + assert!(interrupted.is_err()); + + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + None, )?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["action"], "cleared"); - let raw = std::fs::read_to_string(home.join("familiars.toml"))?; - assert!(!raw.contains("ph:leaf-fill")); + + assert_eq!(retry.status, 200, "got {}", retry.body); + assert_eq!( + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "after" + ); Ok(()) } #[test] - fn put_familiar_icon_returns_404_for_unknown_id() -> Result<()> { + fn threads_scheduler_opens_window_once_and_waits_until_deadline() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; - let response = handle_request_with_body( - "PUT", - "/api/v1/familiars/ghost/icon", + let veto = coven_threads_core::VetoWindow::new( + std::time::Duration::from_secs(300), + std::time::Duration::from_secs(60), + ); + let (_, proposal_id) = stage_scheduled_reviewed_edit( home, - None, - Some(r#"{"icon":"ph:ghost-fill"}"#), + coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, + time::OffsetDateTime::now_utc(), )?; - assert_eq!(response.status, 404); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "familiar_not_found"); + + assert_eq!(process_due_threads_proposals(home)?, 0); + assert_eq!(process_due_threads_proposals(home)?, 0); + assert_eq!( + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "before" + ); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_window_opened'", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!( + count, + 1, + "scheduler log: {}", + std::fs::read_to_string(crate::daemon::daemon_recovery_log_path(home)) + .unwrap_or_default() + ); Ok(()) } #[test] - fn put_familiar_icon_rejects_non_string_icon() -> Result<()> { + fn threads_scheduler_applies_due_delayed_proposal() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; - let response = handle_request_with_body( - "PUT", - "/api/v1/familiars/sage/icon", + let veto = coven_threads_core::VetoWindow::new( + std::time::Duration::from_secs(300), + std::time::Duration::from_secs(60), + ); + let (_, proposal_id) = stage_scheduled_reviewed_edit( home, - None, - Some(r#"{"icon":[1,2,3]}"#), + coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, + time::OffsetDateTime::now_utc() - time::Duration::minutes(10), )?; - assert_eq!(response.status, 400); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "invalid_request"); - // File must be untouched. - let raw = std::fs::read_to_string(home.join("familiars.toml"))?; - assert!(raw.contains("ph:leaf-fill")); + + let completed = process_due_threads_proposals(home)?; + assert_eq!( + completed, + 1, + "scheduler log: {}", + std::fs::read_to_string(crate::daemon::daemon_recovery_log_path(home)) + .unwrap_or_default() + ); + + assert_eq!( + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "after" + ); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let events: Vec = { + let mut statement = conn.prepare( + "SELECT event_type FROM ward_audit + WHERE proposal_id = ?1 ORDER BY id", + )?; + let rows = statement + .query_map([&proposal_id], |row| row.get(0))? + .collect::>()?; + rows + }; + assert_eq!( + events.first().map(String::as_str), + Some("proposal_window_opened") + ); + assert_eq!(events.last().map(String::as_str), Some("proposal_approved")); + assert_eq!( + events + .iter() + .filter(|event| event.as_str() == "proposal_window_opened") + .count(), + 1 + ); Ok(()) } #[test] - fn put_familiar_icon_with_empty_body_clears() -> Result<()> { + fn threads_scheduler_recovers_durable_approval_claim() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; - let response = - handle_request_with_body("PUT", "/api/v1/familiars/sage/icon", home, None, None)?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["action"], "cleared"); - Ok(()) - } - - // ---- POST /api/v1/familiars/{id}/edits (Ward write path) ------------- - - /// Seed a warded sage: familiars.toml plus a workspace carrying a - /// ward.toml with SOUL.md protected (tier 0), reviewed/ tier 1, and the - /// default tier 2 everywhere else. Returns the workspace path. - fn seed_warded_familiar(home: &Path) -> Result { - seed_familiars_toml(home)?; - let workspace = home.join("familiars").join("sage"); - std::fs::create_dir_all(&workspace)?; - std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; - std::fs::write( - workspace.join("ward.toml"), - r#"principal_key_fingerprint = "fpr-val" -protected_surface = ["SOUL.md"] + let (_, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApprovalWithRationale, + time::OffsetDateTime::now_utc(), + )?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ApplyBeforeAudit, + proposal_id.clone(), + ))); + let interrupted = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some(r#"{"note":"scheduler recovery"}"#), + ); + assert!(interrupted.is_err()); -[[surface]] -path = "SOUL.md" -tier = 0 + assert_eq!(process_due_threads_proposals(home)?, 1); -[[surface]] -path = "reviewed/" -tier = 1 -"#, + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let detail: String = conn.query_row( + "SELECT detail FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_approved'", + [&proposal_id], + |row| row.get(0), )?; - Ok(workspace) + let detail: coven_threads_core::ProposalApprovalAuditDetail = + serde_json::from_str(&detail)?; + assert_eq!(detail.rationale.as_deref(), Some("scheduler recovery")); + Ok(()) } - fn post_edits(home: &Path, body: &str) -> Result { - handle_request_with_body( + #[test] + fn threads_scheduler_recovers_rationale_persisted_at_claim_time() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (_, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApprovalWithRationale, + time::OffsetDateTime::now_utc(), + )?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ClaimBeforeValidation, + proposal_id.clone(), + ))); + let interrupted = handle_request_with_body( "POST", - "/api/v1/familiars/sage/edits", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(body), - ) + Some(r#"{"note":"claim-time rationale"}"#), + ); + assert!(interrupted.is_err()); + + assert_eq!(process_due_threads_proposals(home)?, 1); + + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let detail: String = conn.query_row( + "SELECT detail FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_approved'", + [&proposal_id], + |row| row.get(0), + )?; + let detail: coven_threads_core::ProposalApprovalAuditDetail = + serde_json::from_str(&detail)?; + assert_eq!(detail.rationale.as_deref(), Some("claim-time rationale")); + Ok(()) } #[test] - fn post_familiar_edits_applies_and_audits_tier2_write() -> Result<()> { + fn threads_scheduler_recovers_veto_claimed_before_deadline() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let workspace = seed_warded_familiar(home)?; - - let response = post_edits( + let veto = coven_threads_core::VetoWindow::new( + std::time::Duration::from_secs(2), + std::time::Duration::ZERO, + ); + let (_, proposal_id) = stage_scheduled_reviewed_edit( home, - r#"{"edits":[{"target":"notes/today.md","contents":"hello ward"}]}"#, + coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, + time::OffsetDateTime::now_utc(), )?; - - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["ok"], true); - assert_eq!(body["disposition"], "applied"); - assert_eq!(body["changes"][0]["tier"], 2); - assert_eq!(body["changes"][0]["disposition"], "applied"); - // Gate 4: a tier-2 write must carry a tamper-evident audit record. - assert!( - body["changes"][0]["audit"]["nextSha256"].is_string(), - "expected audit record, got {}", - body["changes"][0] - ); - assert_eq!( - std::fs::read_to_string(workspace.join("notes/today.md"))?, - "hello ward" + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ClaimBeforeValidation, + proposal_id.clone(), + ))); + let interrupted = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/reject"), + home, + None, + Some(r#"{"note":"timely veto"}"#), ); + assert!(interrupted.is_err()); + std::thread::sleep(std::time::Duration::from_millis(2_100)); + + let recovered = process_due_threads_proposals(home)?; + assert_eq!(recovered, 1); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let event: String = conn.query_row( + "SELECT event_type FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_vetoed'", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!(event, "proposal_vetoed"); Ok(()) } #[test] - fn post_familiar_edits_refuses_traversal_and_writes_nothing() -> Result<()> { + fn invalid_manual_decision_does_not_block_automatic_apply() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let workspace = seed_warded_familiar(home)?; + let (_, proposal_id) = stage_scheduled_edit( + home, + "logged/skill.md", + 2, + coven_threads_core::ApprovalPath::AutoRegression { veto: None }, + time::OffsetDateTime::now_utc(), + coven_threads_core::Channel::Mutation, + )?; - // All-or-nothing: a clean tier-2 edit bundled with a traversal escape - // must not be written either. - let response = post_edits( + let rejected = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/reject"), home, - r#"{"edits":[ - {"target":"notes/ok.md","contents":"fine"}, - {"target":"../escape.md","contents":"nope"} - ]}"#, + None, + Some("{}"), )?; + assert_eq!(rejected.status, 409); + assert!(rejected.body.contains("proposal-not-human-decidable")); - assert_eq!(response.status, 403, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "ward_refused"); - assert!(!workspace.join("notes/ok.md").exists()); - assert!(!home.join("familiars/escape.md").exists()); + let processed = process_due_threads_proposals(home)?; + assert_eq!(processed, 1); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let event: String = conn.query_row( + "SELECT event_type FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_approved'", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!(event, "proposal_approved"); Ok(()) } #[test] - fn post_familiar_edits_refuses_unsigned_protected_write() -> Result<()> { + fn threads_approve_revalidates_applies_audits_and_removes_pending() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let workspace = seed_warded_familiar(home)?; + let (pending, proposal_id) = stage_pending_protected_edit(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; - let response = post_edits( + let response = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, - r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}]}"#, + None, + Some(r#"{"note":"principal reviewed"}"#), )?; - assert_eq!(response.status, 403, "got {}", response.body); + assert_eq!(response.status, 200, "got {}", response.body); let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "ward_refused"); - assert_eq!( - body["error"]["details"]["changes"][0]["verdict"]["kind"], - "blocked" - ); + assert_eq!(body["decision"], "approved"); + assert_eq!(body["proposalId"], proposal_id); assert_eq!( std::fs::read_to_string(workspace.join("SOUL.md"))?, - "# Sage\n" + "approved identity" ); + assert!(!pending.exists(), "approved proposal must be removed"); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let (event_type, detail): (String, String) = conn.query_row( + "SELECT event_type, detail + FROM ward_audit + WHERE proposal_id = ?1 + ORDER BY id DESC + LIMIT 1", + [&proposal_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + assert_eq!(event_type, "proposal_approved"); + let detail: coven_threads_core::ProposalApprovalAuditDetail = + serde_json::from_str(&detail)?; + assert_eq!(detail.approval_path_label, "human_review"); + assert_eq!(detail.rationale.as_deref(), Some("principal reviewed")); + assert_eq!(detail.window_close, None); Ok(()) } #[test] - fn post_familiar_edits_holds_authorized_protected_write() -> Result<()> { + fn threads_approve_recovers_after_apply_before_audit() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let workspace = seed_warded_familiar(home)?; + let (pending, proposal_id) = stage_pending_protected_edit(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ApplyBeforeAudit, + proposal_id.clone(), + ))); - // Gate 1 passes, but Gate 3 (coherence) is unimplemented: held, not - // written — fail-closed. - let response = post_edits( + let first = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, - r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], - "principalKeyFingerprint":"fpr-val"}"#, - )?; + None, + Some(r#"{"note":"principal reviewed"}"#), + ); - assert_eq!(response.status, 202, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["disposition"], "held"); - // An authorized-protected hold is the authority lane's business — - // it must NOT be staged into the Gate-3 coherence lane. - assert!(body.get("reviewKind").is_none()); + assert!(first.is_err(), "failpoint must interrupt the decision"); assert_eq!( std::fs::read_to_string(workspace.join("SOUL.md"))?, - "# Sage\n" + "approved identity" ); - // The coven-threads gate ran first and permitted: the verdict is in - // the payload and in the append-only ward_audit ledger. - assert_eq!( - body["threadsGate"]["outcome"]["kind"], "permitted", - "got {}", - response.body + assert!( + !pending.exists(), + "the original file must be atomically claimed" ); + let claim = find_pending_decision_claim(home, &proposal_id, "approve") + .expect("interrupted approval leaves a durable claim"); + assert!(claim.exists()); let conn = store::open_store(&home.join("coven.sqlite3"))?; - let decision: String = conn.query_row( - "SELECT decision FROM ward_audit WHERE familiar_id='sage' ORDER BY id DESC LIMIT 1", - [], + let terminal_count: i64 = conn.query_row( + "SELECT COUNT(*) + FROM ward_audit + WHERE proposal_id = ?1 + AND event_type IN ('proposal_approved', 'proposal_rejected', 'proposal_vetoed')", + [&proposal_id], |row| row.get(0), )?; - assert_eq!(decision, "permit"); + assert_eq!(terminal_count, 0); + drop(conn); + + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some(r#"{"note":"principal reviewed"}"#), + )?; + + assert_eq!(retry.status, 200, "got {}", retry.body); + assert!(!claim.exists(), "successful recovery consumes the claim"); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let approved_count: i64 = conn.query_row( + "SELECT COUNT(*) + FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_approved'", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!(approved_count, 1); Ok(()) } #[test] - fn post_familiar_edits_stages_to_pending_after_out_of_band_drift() -> Result<()> { - // §5 of the coven-threads design (DegradeToProposal), end to end: - // baseline the surface, drift it outside the daemon, then propose — - // the write is staged at ~/.coven/pending/, the surface is untouched, - // and `staged` is the one additive disposition the §6 compatibility - // contract allows. + fn threads_approve_recovery_ward_refusal_restores_retryable_pending() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let workspace = seed_warded_familiar(home)?; + let (pending, proposal_id) = stage_pending_protected_edit(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ApplyBeforeAudit, + proposal_id.clone(), + ))); + let interrupted = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + ); + assert!(interrupted.is_err()); + force_recovery_ward_refusal(proposal_id.clone()); - // First signed request bootstraps the content baseline (held). - let first = post_edits( + let refused = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, - r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], - "principalKeyFingerprint":"fpr-val"}"#, + None, + Some("{}"), )?; - assert_eq!(first.status, 202, "got {}", first.body); - // Out-of-band drift: something edits SOUL.md around the daemon. - std::fs::write(workspace.join("SOUL.md"), "# Mallory\n")?; + assert_eq!(refused.status, 409, "got {}", refused.body); + let body: serde_json::Value = serde_json::from_str(&refused.body)?; + assert_eq!(body["why"], "proposal-recovery-revalidation-failed"); + assert!( + pending.exists(), + "refused recovery must restore pending JSON" + ); + let restored: serde_json::Value = serde_json::from_slice(&std::fs::read(&pending)?)?; + assert!( + restored.get("decisionState").is_none(), + "restored proposal must not retain recovery-only decision state" + ); + assert!( + find_pending_decision_claim(home, &proposal_id, "approve").is_none(), + "refused recovery must consume the claimed filename" + ); - let response = post_edits( + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, - r#"{"edits":[{"target":"SOUL.md","contents":"new identity v2"}], - "principalKeyFingerprint":"fpr-val"}"#, + None, + Some("{}"), )?; - assert_eq!(response.status, 202, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["disposition"], "staged"); - assert_eq!(body["threadsGate"]["outcome"]["kind"], "staged"); + assert_eq!(retry.status, 200, "got {}", retry.body); + Ok(()) + } - let pending = body["threadsGate"]["outcome"]["pendingPath"] - .as_str() - .expect("staged outcome carries pendingPath"); + #[test] + fn threads_approve_recovery_preserves_claim_if_ward_config_diverged() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (pending, proposal_id) = stage_pending_protected_edit(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ApplyBeforeAudit, + proposal_id.clone(), + ))); + let interrupted = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + ); + assert!(interrupted.is_err()); + let claim = find_pending_decision_claim(home, &proposal_id, "approve") + .expect("interrupted approval leaves a recovery claim"); + let ward_path = workspace.join("ward.toml"); + let changed_ward = std::fs::read_to_string(&ward_path)?.replace("fpr-val", "fpr-other"); + std::fs::write(&ward_path, changed_ward)?; + + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + )?; + + assert_eq!(retry.status, 409, "got {}", retry.body); + let body: serde_json::Value = serde_json::from_str(&retry.body)?; + assert_eq!(body["why"], "proposal-recovery-evidence-diverged"); + assert!(claim.exists(), "diverged recovery must retain its claim"); assert!( - std::path::Path::new(pending).exists(), - "pending proposal file must exist" + !pending.exists(), + "claim must remain the sole proposal file" ); - // The staged proposal carries the full desired contents. - let staged: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(pending)?)?; - assert_eq!(staged["edits"][0]["surface"], "SOUL.md"); - // Nothing wrote the protected surface. assert_eq!( std::fs::read_to_string(workspace.join("SOUL.md"))?, - "# Mallory\n" + "approved identity" ); - // The degrade decision is in the append-only ledger. let conn = store::open_store(&home.join("coven.sqlite3"))?; - let decision: String = conn.query_row( - "SELECT decision FROM ward_audit WHERE familiar_id='sage' ORDER BY id DESC LIMIT 1", - [], + let terminal_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM ward_audit + WHERE proposal_id = ?1 + AND event_type IN ('proposal_approved', 'proposal_rejected', 'proposal_vetoed')", + [&proposal_id], |row| row.get(0), )?; - assert_eq!(decision, "degrade_to_proposal"); + assert_eq!(terminal_count, 0); Ok(()) } #[test] - fn post_familiar_edits_blocked_target_refuses_even_with_drifted_surface() -> Result<()> { - // Review finding: a mixed proposal (drifted Tier-0 edit + traversal - // escape) must be refused as a unit BEFORE the threads gate can stage - // it — a blocked target must never ride into ~/.coven/pending/. + fn threads_approve_recovery_preserves_claim_if_ward_disappears() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let workspace = seed_warded_familiar(home)?; - - // Baseline SOUL.md, then drift it so the gate would want to stage. - let first = post_edits( - home, - r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], - "principalKeyFingerprint":"fpr-val"}"#, - )?; - assert_eq!(first.status, 202, "got {}", first.body); - std::fs::write(workspace.join("SOUL.md"), "# Mallory\n")?; - - let response = post_edits( + let (_, proposal_id) = stage_pending_protected_edit(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ApplyBeforeAudit, + proposal_id.clone(), + ))); + let interrupted = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, - r#"{"edits":[ - {"target":"SOUL.md","contents":"new identity v2"}, - {"target":"../escape.md","contents":"nope"} - ], "principalKeyFingerprint":"fpr-val"}"#, - )?; - assert_eq!(response.status, 403, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "ward_refused"); - // Nothing was staged and nothing escaped. - let pending = home.join("pending"); - let staged_count = std::fs::read_dir(&pending) - .map(|entries| entries.count()) - .unwrap_or(0); - assert_eq!(staged_count, 0, "blocked proposal must not stage"); - assert!(!home.join("familiars/escape.md").exists()); - Ok(()) - } + None, + Some("{}"), + ); + assert!(interrupted.is_err()); + let claim = find_pending_decision_claim(home, &proposal_id, "approve") + .expect("interrupted approval leaves a recovery claim"); + std::fs::remove_file(workspace.join("ward.toml"))?; - #[test] - fn post_familiar_edits_editable_tier_bypasses_the_weave() -> Result<()> { - // Editable-tier writes are the Ward tiers' lane: the weave reports no - // verdicts and appends nothing to ward_audit. - let temp = tempfile::tempdir()?; - let home = temp.path(); - seed_warded_familiar(home)?; - let response = post_edits( + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, - r#"{"edits":[{"target":"notes/today.md","contents":"hello ward"}]}"#, + None, + Some("{}"), )?; - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!( - body["threadsGate"]["verdicts"].as_array().map(Vec::len), - Some(0) + + assert_eq!(retry.status, 409, "got {}", retry.body); + let body: Value = serde_json::from_str(&retry.body)?; + assert_eq!(body["why"], "ward-not-configured"); + assert!( + claim.exists(), + "recovery claim must not downgrade to pending" ); - let conn = store::open_store(&home.join("coven.sqlite3"))?; - let count: i64 = conn.query_row("SELECT COUNT(*) FROM ward_audit", [], |row| row.get(0))?; - assert_eq!(count, 0); Ok(()) } #[test] - fn threads_weaves_returns_cave_normalizable_weave_entries() -> Result<()> { + fn pending_claim_search_skips_unrelated_directory_entries() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_warded_familiar(home)?; + let pending = home.join("pending"); + std::fs::create_dir_all(&pending)?; + std::fs::write(pending.join("000-unrelated"), "junk")?; + let proposal_id = Uuid::new_v4().to_string(); + let claim = pending.join(format!( + "{}-{proposal_id}.json.approve.deciding", + Uuid::new_v4() + )); + std::fs::write(&claim, "{}")?; - let baseline = post_edits( - home, - r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], - "principalKeyFingerprint":"fpr-val"}"#, - )?; - assert_eq!(baseline.status, 202, "got {}", baseline.body); + let found = find_any_pending_decision_claim(home, &proposal_id); - let response = handle_request("GET", "/api/v1/threads/weaves", home, None)?; - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - let entries = body.as_array().expect("daemon returns a raw entry array"); - assert_eq!(entries.len(), 1); - let entry = &entries[0]; - assert_eq!(entry["coherence"], "Coherent"); - assert_eq!(entry["weave"]["familiar_id"], "sage"); - assert!(entry["weave"]["id"].is_string()); - assert!(entry["weave"]["weave_hash"] - .as_array() - .is_some_and(|v| !v.is_empty())); - assert_eq!(entry["weave"]["threads"][0]["surface"], "SOUL.md"); - assert_eq!(entry["weave"]["threads"][0]["writer"], "principal:fpr-val"); - assert_eq!(entry["weave"]["threads"][0]["tension"], "Holds"); - assert!(entry["weave"]["threads"][0]["created_at"].is_array()); - assert!(entry["weave"]["threads"][0]["strands"] - .as_array() - .is_some_and(|v| !v.is_empty())); - assert!(entry["weave"]["pattern_descriptor"]["name"].is_string()); + assert_eq!(found, Some((claim, "approve".to_string()))); Ok(()) } #[test] - fn threads_weaves_skips_malformed_ward_without_aborting_fleet_read() -> Result<()> { + fn threads_approve_recovery_refuses_concurrent_surface_bytes() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_warded_familiar(home)?; - - let cody_workspace = home.join("familiars").join("cody"); - std::fs::create_dir_all(&cody_workspace)?; - std::fs::write(cody_workspace.join("SOUL.md"), "# Cody\n")?; - std::fs::write( - cody_workspace.join("ward.toml"), - r#"protected_surface = ["SOUL.md"] + let (_, proposal_id) = stage_pending_protected_edit(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::ApplyBeforeAudit, + proposal_id.clone(), + ))); + let first = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + ); + assert!(first.is_err()); + std::fs::write(workspace.join("SOUL.md"), "concurrent bytes")?; -[[surface]] -path = "SOUL.md" -tier = 0 -"#, + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), )?; - let response = handle_request("GET", "/api/v1/threads/weaves", home, None)?; - - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - let entries = body.as_array().expect("weaves response is an array"); + assert_eq!(retry.status, 409, "got {}", retry.body); + let body: serde_json::Value = serde_json::from_str(&retry.body)?; + assert_eq!(body["why"], "proposal-recovery-surface-diverged"); + let baseline = ward_manifest_entry_hash(home, "sage", "SOUL.md")?; assert_eq!( - entries.len(), - 2, - "malformed cody ward must not abort healthy weave or vanish" - ); - let healthy = entries - .iter() - .find(|entry| entry.get("weave").is_some()) - .expect("healthy weave is still listed"); - assert_eq!(healthy["weave"]["familiar_id"], "sage"); - let degraded = entries - .iter() - .find_map(|entry| entry.get("degraded")) - .expect("malformed ward appears as a degraded familiar"); - assert_eq!(degraded["familiarId"], "cody"); - assert_eq!(degraded["reason"], "ward-config-unparseable"); - let error = degraded["error"].as_str().expect("error string"); - assert!( - !error.contains('\n'), - "error must be single-line: {error:?}" - ); - assert!( - error.contains("ward.toml"), - "error names ward.toml: {error}" - ); - assert!( - !error.contains(&home.display().to_string()), - "error must not leak absolute home path: {error}" - ); - assert!( - !error.contains("/familiars/cody/ward.toml"), - "error must not leak rooted ward path: {error}" + baseline, + coven_threads_core::manifest_entry_hash( + &coven_threads_core::SurfaceId::new("SOUL.md"), + b"# Sage\n", + ) + .to_vec(), + "unapproved concurrent bytes must not become the baseline" ); Ok(()) } #[test] - fn threads_weaves_omits_familiars_without_ward_toml() -> Result<()> { + fn threads_approve_retry_after_audit_is_idempotent() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_warded_familiar(home)?; - let cody_workspace = home.join("familiars").join("cody"); - std::fs::create_dir_all(&cody_workspace)?; - std::fs::write(cody_workspace.join("SOUL.md"), "# Cody\n")?; - - let response = handle_request("GET", "/api/v1/threads/weaves", home, None)?; + let (_, proposal_id) = stage_pending_protected_edit(home)?; + let workspace = home.join("familiars").join("sage"); + std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::AuditBeforeCleanup, + proposal_id.clone(), + ))); - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - let entries = body.as_array().expect("weaves response is an array"); - assert_eq!(entries.len(), 1, "no-ward familiar should be skipped"); - assert_eq!(entries[0]["weave"]["familiar_id"], "sage"); - assert!( - entries.iter().all(|entry| entry.get("degraded").is_none()), - "no-ward familiar is not degraded: {entries:?}" + let first = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), ); - Ok(()) - } - fn stage_pending_protected_edit(home: &Path) -> Result<(std::path::PathBuf, String)> { - let workspace = seed_warded_familiar(home)?; - let baseline = post_edits( + assert!(first.is_err(), "failpoint must interrupt pending cleanup"); + let claim = find_pending_decision_claim(home, &proposal_id, "approve") + .expect("committed approval leaves its claim until recovery"); + assert!(claim.exists()); + + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, - r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}], - "principalKeyFingerprint":"fpr-val"}"#, + None, + Some("{}"), )?; - assert_eq!(baseline.status, 202, "got {}", baseline.body); - std::fs::write(workspace.join("SOUL.md"), "# Mallory\n")?; - let staged = post_edits( - home, - r#"{"edits":[{"target":"SOUL.md","contents":"approved identity"}], - "principalKeyFingerprint":"fpr-val"}"#, + + assert_eq!(retry.status, 200, "got {}", retry.body); + assert!(!claim.exists()); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let approved_count: i64 = conn.query_row( + "SELECT COUNT(*) + FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_approved'", + [&proposal_id], + |row| row.get(0), )?; - assert_eq!(staged.status, 202, "got {}", staged.body); - let body: serde_json::Value = serde_json::from_str(&staged.body)?; - let pending = std::path::PathBuf::from( - body["threadsGate"]["outcome"]["pendingPath"] - .as_str() - .expect("staged response carries pendingPath"), - ); - let proposal_id = body["threadsGate"]["outcome"]["proposalId"] - .as_str() - .expect("staged response carries proposalId") - .to_string(); - Ok((pending, proposal_id)) + assert_eq!(approved_count, 1, "retry must not duplicate terminal audit"); + Ok(()) } #[test] - fn threads_approve_revalidates_applies_audits_and_removes_pending() -> Result<()> { + fn threads_completed_decision_uses_terminal_audit_without_pending_file() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - let (pending, proposal_id) = stage_pending_protected_edit(home)?; + let (_, proposal_id) = stage_pending_protected_edit(home)?; let workspace = home.join("familiars").join("sage"); std::fs::write(workspace.join("SOUL.md"), "# Sage\n")?; + let approved = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + )?; + assert_eq!(approved.status, 200, "got {}", approved.body); - let response = handle_request_with_body( + let retry = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"note":"principal reviewed"}"#), + Some("{}"), )?; + assert_eq!(retry.status, 200, "got {}", retry.body); + let retry_body: serde_json::Value = serde_json::from_str(&retry.body)?; + assert_eq!(retry_body["idempotent"], true); - assert_eq!(response.status, 200, "got {}", response.body); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["decision"], "approved"); - assert_eq!(body["proposalId"], proposal_id); - assert_eq!( - std::fs::read_to_string(workspace.join("SOUL.md"))?, - "approved identity" - ); - assert!(!pending.exists(), "approved proposal must be removed"); - let conn = store::open_store(&home.join("coven.sqlite3"))?; - let (event_type, detail): (String, String) = conn.query_row( - "SELECT event_type, detail - FROM ward_audit - WHERE proposal_id = ?1 - ORDER BY id DESC - LIMIT 1", - [&proposal_id], - |row| Ok((row.get(0)?, row.get(1)?)), + let opposite = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/reject"), + home, + None, + Some("{}"), )?; - assert_eq!(event_type, "proposal_approved"); - let detail: coven_threads_core::ProposalApprovalAuditDetail = - serde_json::from_str(&detail)?; - assert_eq!(detail.approval_path_label, "human_review"); - assert_eq!(detail.rationale.as_deref(), Some("principal reviewed")); - assert_eq!(detail.window_close, None); + assert_eq!(opposite.status, 409, "got {}", opposite.body); + let opposite_body: serde_json::Value = serde_json::from_str(&opposite.body)?; + assert_eq!(opposite_body["why"], "proposal-already-decided"); Ok(()) } @@ -7547,6 +10096,50 @@ tier = 0 Ok(()) } + #[test] + fn threads_reject_retry_after_audit_is_idempotent() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (_, proposal_id) = stage_pending_protected_edit(home)?; + set_proposal_decision_failpoint(Some(( + ProposalDecisionFailpoint::AuditBeforeCleanup, + proposal_id.clone(), + ))); + + let first = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/reject"), + home, + None, + Some("{}"), + ); + + assert!(first.is_err(), "failpoint must interrupt pending cleanup"); + let claim = find_pending_decision_claim(home, &proposal_id, "reject") + .expect("committed rejection leaves its claim until recovery"); + + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/reject"), + home, + None, + Some("{}"), + )?; + + assert_eq!(retry.status, 200, "got {}", retry.body); + assert!(!claim.exists()); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + let rejected_count: i64 = conn.query_row( + "SELECT COUNT(*) + FROM ward_audit + WHERE proposal_id = ?1 AND event_type = 'proposal_rejected'", + [&proposal_id], + |row| row.get(0), + )?; + assert_eq!(rejected_count, 1); + Ok(()) + } + #[test] fn threads_decision_preserves_ward_audit_append_only() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/crates/coven-cli/src/daemon.rs b/crates/coven-cli/src/daemon.rs index 6110d82a..b19f8373 100644 --- a/crates/coven-cli/src/daemon.rs +++ b/crates/coven-cli/src/daemon.rs @@ -1865,6 +1865,29 @@ pub fn append_daemon_recovery_log(coven_home: &Path, msg: &str) { } } +fn start_threads_proposal_scheduler(coven_home: &Path) -> Result<()> { + if let Err(error) = crate::api::process_due_threads_proposals(coven_home) { + append_daemon_recovery_log( + coven_home, + &format!("threads scheduler startup pass failed: {error:#}"), + ); + } + let home = coven_home.to_path_buf(); + std::thread::Builder::new() + .name("coven-threads-scheduler".into()) + .spawn(move || loop { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Err(error) = crate::api::process_due_threads_proposals(&home) { + append_daemon_recovery_log( + &home, + &format!("threads scheduler pass failed: {error:#}"), + ); + } + }) + .context("failed to spawn threads proposal scheduler")?; + Ok(()) +} + /// Cleans up the Unix-domain socket file and `daemon.json` when the daemon /// exits via any path that runs destructors — normal return, `Err` propagation, /// or panic unwinding. This is what prevents orphaned `~/.coven/coven.sock` @@ -2073,6 +2096,7 @@ pub fn serve_forever( let runtime = Arc::new(LiveSessionRuntime::with_coven_home( coven_home.to_path_buf(), )); + start_threads_proposal_scheduler(coven_home)?; if let Some(addr) = tcp_addr { let tcp_listener = bind_tcp_listener(addr)?; @@ -2583,6 +2607,7 @@ pub fn serve_forever( let runtime = Arc::new(LiveSessionRuntime::with_coven_home( coven_home.to_path_buf(), )); + start_threads_proposal_scheduler(coven_home)?; const MAX_INFLIGHT: usize = 64; let inflight = Arc::new(AtomicUsize::new(0)); diff --git a/crates/coven-cli/src/proposal_scheduler.rs b/crates/coven-cli/src/proposal_scheduler.rs index 07848855..f3521932 100644 --- a/crates/coven-cli/src/proposal_scheduler.rs +++ b/crates/coven-cli/src/proposal_scheduler.rs @@ -145,9 +145,17 @@ impl ScheduledProposal { &self.classification } + pub(crate) fn materialized_diff(&self) -> &threads::MaterializedDiff { + &self.materialized_diff + } + pub(crate) fn earliest_close(&self) -> Option { self.earliest_close } + + pub(crate) fn veto_deadline(&self) -> Option { + self.veto_deadline + } } impl TryFrom for ScheduledProposal { diff --git a/crates/coven-cli/src/threads_gate.rs b/crates/coven-cli/src/threads_gate.rs index d33f0b8a..045a9007 100644 --- a/crates/coven-cli/src/threads_gate.rs +++ b/crates/coven-cli/src/threads_gate.rs @@ -248,10 +248,31 @@ pub(crate) fn build_weave_state( config: &ward::WardConfig, extra_targets: &[String], bootstrap_missing_baselines: bool, +) -> Result { + build_weave_state_for_writer( + conn, + familiar_id, + workspace, + config, + extra_targets, + bootstrap_missing_baselines, + None, + ) +} + +pub(crate) fn build_weave_state_for_writer( + conn: &Connection, + familiar_id: &str, + workspace: &Path, + config: &ward::WardConfig, + extra_targets: &[String], + bootstrap_missing_baselines: bool, + writer: Option<&threads::WriterId>, ) -> Result { let familiar_uuid = familiar_weave_id(familiar_id); - let principal_writer = - threads::WriterId::new(format!("principal:{}", config.principal_key_fingerprint)); + let principal_writer = writer.cloned().unwrap_or_else(|| { + threads::WriterId::new(format!("principal:{}", config.principal_key_fingerprint)) + }); // Weave one thread per protected surface: the literal (non-glob) tier-0 // declarations plus any resolved protected targets being replayed. @@ -374,7 +395,7 @@ pub(crate) fn familiar_weave_id(familiar_id: &str) -> threads::FamiliarId { /// `..`/`.` segments, no symlinks anywhere in the path (intermediate /// directories included), and the read is capped so a pathological /// declaration cannot balloon memory. -fn read_surface(workspace: &Path, surface: &str) -> Result> { +pub(crate) fn read_surface(workspace: &Path, surface: &str) -> Result> { const MAX_SURFACE_BYTES: u64 = 16 * 1024 * 1024; if surface.starts_with('/') || surface.starts_with('\\') { @@ -465,7 +486,11 @@ fn load_or_create_manifest_id(conn: &Connection, familiar_id: &str) -> Result Result>> { +pub(crate) fn load_baseline( + conn: &Connection, + familiar_id: &str, + surface: &str, +) -> Result>> { conn.query_row( "SELECT entry_hash FROM ward_manifest WHERE familiar_id = ?1 AND surface = ?2", params![familiar_id, surface], @@ -505,11 +530,25 @@ pub(crate) fn advance_surface_baseline( familiar_id: &str, workspace: &Path, surface: &str, +) -> Result<()> { + let disk = read_surface(workspace, surface)?; + advance_surface_baseline_from_bytes(conn, familiar_id, workspace, surface, &disk) +} + +pub(crate) fn advance_surface_baseline_from_bytes( + conn: &Connection, + familiar_id: &str, + workspace: &Path, + surface: &str, + expected_bytes: &[u8], ) -> Result<()> { let manifest_id = load_or_create_manifest_id(conn, familiar_id)?; let surface_id = threads::SurfaceId::new(surface.to_string()); let disk = read_surface(workspace, surface)?; - let entry_hash = threads::manifest_entry_hash(&surface_id, &disk); + if disk != expected_bytes { + anyhow::bail!("surface `{surface}` changed after approved apply; baseline not advanced"); + } + let entry_hash = threads::manifest_entry_hash(&surface_id, expected_bytes); store_baseline(conn, familiar_id, surface, &manifest_id, &entry_hash) } diff --git a/crates/coven-cli/src/ward.rs b/crates/coven-cli/src/ward.rs index 8ce792da..3290913a 100644 --- a/crates/coven-cli/src/ward.rs +++ b/crates/coven-cli/src/ward.rs @@ -66,9 +66,13 @@ //! also point the Gate 4 pre-write audit read at an attacker-chosen readable //! file, affecting only the recorded `prev_sha256` — never where bytes land. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::ffi::CString; use std::fs::OpenOptions; use std::io::{ErrorKind, Write}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::ffi::OsStrExt; use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; @@ -724,6 +728,7 @@ impl Ward { &self, edits: &[FileEdit], authorization: &Authorization, + expected_before: &BTreeMap>, ) -> Result { let proposal = Proposal { targets: edits.iter().map(|e| e.target.clone()).collect(), @@ -751,16 +756,12 @@ impl Ward { .home .canonicalize() .with_context(|| format!("ward home `{}` is not resolvable", self.home.display()))?; - let mut changes = Vec::with_capacity(edits.len()); - for (edit, decision) in edits.iter().zip(outcome.decisions) { - let abs = join_resolved(&canonical_home, &decision.resolved); - let audit = write_atomic(&canonical_home, &abs, &edit.new_contents, &decision)?; - changes.push(AppliedChange { - decision, - disposition: Disposition::Applied, - audit, - }); - } + let changes = write_atomically_if_unchanged( + &canonical_home, + edits, + outcome.decisions, + expected_before, + )?; Ok(ApplyReport { changes }) } } @@ -856,6 +857,387 @@ fn write_atomic( Ok(audit) } +struct PreparedConditionalWrite { + path: PathBuf, + staged: Option, + already_applied: bool, + expected_before: Vec, + new_contents: Vec, + decision: Decision, +} + +/// Commit an approved proposal only while every target still has the exact +/// before-image reviewed by the scheduler. +/// +/// Existing targets are exchanged atomically with randomized sibling staging +/// files. The displaced inode is then compared with the approved before-image; +/// a mismatch swaps it back and rolls back every earlier edit in the batch. +/// Already-applied bytes are accepted so crash recovery remains idempotent. +fn write_atomically_if_unchanged( + canonical_home: &Path, + edits: &[FileEdit], + decisions: Vec, + expected_before: &BTreeMap>, +) -> Result> { + let mut prepared = Vec::with_capacity(edits.len()); + let mut preparation_error = None; + for (edit, decision) in edits.iter().zip(decisions) { + let result = (|| -> Result { + let expected = expected_before + .get(&edit.target) + .with_context(|| format!("missing approved before-image for `{}`", edit.target))? + .clone(); + let resolved = join_resolved(canonical_home, &decision.resolved); + let parent = resolved + .parent() + .ok_or_else(|| anyhow!("target has no parent directory: {}", resolved.display()))?; + let name = resolved + .file_name() + .ok_or_else(|| anyhow!("target has no file name: {}", resolved.display()))?; + let canonical_parent = prepare_staging_parent(canonical_home, parent)?; + let path = canonical_parent.join(name); + let current = std::fs::read(&path) + .with_context(|| format!("reading approved target {}", path.display()))?; + let already_applied = current == edit.new_contents; + if current != expected && !already_applied { + bail!( + "approved target `{}` changed after review; refusing to overwrite it", + edit.target + ); + } + Ok(PreparedConditionalWrite { + path, + staged: None, + already_applied, + expected_before: expected, + new_contents: edit.new_contents.clone(), + decision, + }) + })(); + match result { + Ok(write) => prepared.push(write), + Err(error) => { + if preparation_error.is_none() { + preparation_error = Some(error); + } + } + } + } + if let Some(error) = preparation_error { + return fail_after_conditional_rollback(&prepared, &[], error); + } + + for index in 0..prepared.len() { + if prepared[index].already_applied { + continue; + } + match stage_contents(&prepared[index].path, &prepared[index].new_contents) { + Ok(staged) => prepared[index].staged = Some(staged), + Err(error) => return fail_after_conditional_rollback(&prepared, &[], error), + } + } + + let mut swapped = Vec::new(); + for (index, write) in prepared.iter().enumerate() { + let Some(staged) = &write.staged else { + continue; + }; + if let Err(error) = maybe_run_conditional_write_hook(&write.path) { + return fail_after_conditional_rollback(&prepared, &swapped, error); + } + if let Err(error) = atomic_exchange(staged, &write.path) { + return fail_after_conditional_rollback( + &prepared, + &swapped, + error.context(format!( + "committing approved write to {}", + write.path.display() + )), + ); + } + swapped.push(index); + + let verification = (|| -> Result<()> { + let displaced = std::fs::read(staged) + .with_context(|| format!("reading displaced target {}", staged.display()))?; + let installed = std::fs::read(&write.path) + .with_context(|| format!("verifying approved write {}", write.path.display()))?; + if displaced != write.expected_before || installed != write.new_contents { + bail!( + "approved target `{}` changed during commit", + write.decision.target + ); + } + Ok(()) + })(); + if let Err(error) = verification { + return fail_after_conditional_rollback(&prepared, &swapped, error); + } + } + + let final_verification = (|| -> Result<()> { + for write in &prepared { + let current = std::fs::read(&write.path).with_context(|| { + format!("revalidating approved target {}", write.path.display()) + })?; + if current != write.new_contents { + bail!( + "approved target `{}` changed before batch finalization", + write.decision.target + ); + } + } + Ok(()) + })(); + if let Err(error) = final_verification { + return fail_after_conditional_rollback(&prepared, &swapped, error); + } + + for write in &prepared { + if let Some(staged) = &write.staged { + std::fs::remove_file(staged) + .with_context(|| format!("removing approved-write backup {}", staged.display()))?; + } + } + + Ok(prepared + .into_iter() + .map(|write| { + let audit = (write.decision.tier == Tier::Logged).then(|| AuditRecord { + target: write.decision.target.clone(), + resolved: write.decision.resolved.clone(), + tier: write.decision.tier, + prev_sha256: Some(sha256_hex(&write.expected_before)), + next_sha256: sha256_hex(&write.new_contents), + bytes_written: write.new_contents.len(), + }); + AppliedChange { + decision: write.decision, + disposition: Disposition::Applied, + audit, + } + }) + .collect()) +} + +fn rollback_conditional_writes( + prepared: &[PreparedConditionalWrite], + swapped: &[usize], +) -> Result<()> { + let mut errors = Vec::new(); + for &index in swapped.iter().rev() { + let write = &prepared[index]; + let staged = write + .staged + .as_ref() + .context("swapped conditional write has no staging path")?; + if let Err(error) = restore_swapped_write(write, staged) { + errors.push(format!("{}: {error:#}", write.decision.target)); + } + } + for write in prepared.iter().rev().filter(|write| write.already_applied) { + if write.expected_before != write.new_contents { + errors.push(format!( + "{}: approved bytes predated this apply attempt; ownership is unproven, so \ + recovery left them in place", + write.decision.target + )); + } + } + if errors.is_empty() { + Ok(()) + } else { + bail!("conditional rollback failed: {}", errors.join("; ")) + } +} + +fn fail_after_conditional_rollback( + prepared: &[PreparedConditionalWrite], + swapped: &[usize], + error: anyhow::Error, +) -> Result> { + let rollback = rollback_conditional_writes(prepared, swapped); + match rollback { + Ok(()) => { + cleanup_conditional_staging(prepared); + Err(error.context("approved proposal was rolled back")) + } + Err(rollback_error) => Err(anyhow!( + "{error:#}; conditional rollback also failed: {rollback_error:#}" + )), + } +} + +fn restore_swapped_write(write: &PreparedConditionalWrite, staged: &Path) -> Result<()> { + let restore_bytes = std::fs::read(staged) + .with_context(|| format!("reading rollback source {}", staged.display()))?; + atomic_exchange(staged, &write.path).with_context(|| { + format!( + "restoring approved target {} from {}", + write.path.display(), + staged.display() + ) + })?; + let verification = verify_rollback_exchange(write, staged, &restore_bytes); + if let Err(error) = verification { + atomic_exchange(staged, &write.path).with_context(|| { + format!( + "putting concurrent bytes back at {} after rollback verification failed: \ + {error:#}", + write.path.display() + ) + })?; + return Err(error); + } + Ok(()) +} + +fn verify_rollback_exchange( + write: &PreparedConditionalWrite, + displaced: &Path, + expected_restored: &[u8], +) -> Result<()> { + let displaced = std::fs::read(displaced).with_context(|| { + format!( + "reading rollback-displaced bytes for {}", + write.path.display() + ) + })?; + let restored = std::fs::read(&write.path) + .with_context(|| format!("verifying rollback of {}", write.path.display()))?; + if displaced != write.new_contents || restored != expected_restored { + bail!( + "target changed while rolling back approved write {}", + write.path.display() + ); + } + Ok(()) +} + +fn cleanup_conditional_staging(prepared: &[PreparedConditionalWrite]) { + for write in prepared { + if let Some(staged) = &write.staged { + let _ = std::fs::remove_file(staged); + } + } +} + +pub(crate) const fn supports_atomic_approved_writes() -> bool { + cfg!(any(target_os = "linux", target_os = "macos")) +} + +fn stage_contents(path: &Path, contents: &[u8]) -> Result { + let (staged, mut file) = create_staging_file(path)?; + let result = (|| -> Result<()> { + file.write_all(contents) + .with_context(|| format!("staging write to {}", staged.display()))?; + file.sync_all() + .with_context(|| format!("syncing staged write to {}", staged.display()))?; + Ok(()) + })(); + drop(file); + if let Err(error) = result { + let _ = std::fs::remove_file(&staged); + return Err(error); + } + Ok(staged) +} + +#[cfg(test)] +type ConditionalWriteHook = std::sync::Mutex)>>>; + +#[cfg(test)] +fn conditional_write_hook() -> &'static ConditionalWriteHook { + static HOOK: std::sync::OnceLock = std::sync::OnceLock::new(); + HOOK.get_or_init(|| std::sync::Mutex::new(BTreeMap::new())) +} + +#[cfg(test)] +fn set_conditional_write_hook(path: PathBuf, replacement: Vec) { + set_conditional_write_actions(path.clone(), vec![(path, replacement)]); +} + +#[cfg(test)] +fn set_conditional_write_actions(trigger: PathBuf, actions: Vec<(PathBuf, Vec)>) { + conditional_write_hook() + .lock() + .expect("conditional write hook lock poisoned") + .insert(trigger, actions); +} + +#[cfg(test)] +fn maybe_run_conditional_write_hook(path: &Path) -> Result<()> { + let mut hook = conditional_write_hook() + .lock() + .expect("conditional write hook lock poisoned"); + if let Some(actions) = hook.remove(path) { + for (target, replacement) in actions { + std::fs::write(&target, replacement).with_context(|| { + format!( + "running conditional write test hook for {}", + target.display() + ) + })?; + } + } + Ok(()) +} + +#[cfg(not(test))] +fn maybe_run_conditional_write_hook(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(target_os = "macos")] +fn atomic_exchange(left: &Path, right: &Path) -> Result<()> { + const RENAME_SWAP: u32 = 0x0000_0002; + unsafe extern "C" { + fn renamex_np( + from: *const libc::c_char, + to: *const libc::c_char, + flags: u32, + ) -> libc::c_int; + } + + let left = CString::new(left.as_os_str().as_bytes()).context("staging path contains NUL")?; + let right = CString::new(right.as_os_str().as_bytes()).context("target path contains NUL")?; + // SAFETY: both C strings are NUL-terminated and remain alive for the call. + let result = unsafe { renamex_np(left.as_ptr(), right.as_ptr(), RENAME_SWAP) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).context("atomically exchanging approved target") + } +} + +#[cfg(target_os = "linux")] +fn atomic_exchange(left: &Path, right: &Path) -> Result<()> { + const RENAME_EXCHANGE: libc::c_uint = 1 << 1; + let left = CString::new(left.as_os_str().as_bytes()).context("staging path contains NUL")?; + let right = CString::new(right.as_os_str().as_bytes()).context("target path contains NUL")?; + // SAFETY: both C strings are NUL-terminated and remain alive for the syscall. + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + left.as_ptr(), + libc::AT_FDCWD, + right.as_ptr(), + RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).context("atomically exchanging approved target") + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn atomic_exchange(_left: &Path, _right: &Path) -> Result<()> { + bail!("atomic approved-write exchange is unsupported on this platform") +} + /// Create a fresh sibling staging file for an atomic write. /// /// The staging path is intentionally unpredictable and opened with @@ -1485,6 +1867,170 @@ tier = 2 assert!(!tmp.path().join("SOUL.md").exists()); } + #[test] + fn approved_apply_rolls_back_batch_if_target_changes_immediately_before_commit() { + let tmp = tempfile::tempdir().unwrap(); + let ward = ward_in(tmp.path()); + fs::write(tmp.path().join("SOUL.md"), b"old soul").unwrap(); + fs::write(tmp.path().join("IDENTITY.md"), b"old identity").unwrap(); + let edits = vec![ + FileEdit::new("SOUL.md", b"new soul".to_vec()), + FileEdit::new("IDENTITY.md", b"new identity".to_vec()), + ]; + let expected = BTreeMap::from([ + ("SOUL.md".to_string(), b"old soul".to_vec()), + ("IDENTITY.md".to_string(), b"old identity".to_vec()), + ]); + set_conditional_write_hook( + tmp.path().canonicalize().unwrap().join("IDENTITY.md"), + b"concurrent identity".to_vec(), + ); + + let error = ward + .apply_after_threads_approval( + &edits, + &Authorization::signed_by("SHA256:principal-key"), + &expected, + ) + .expect_err("concurrent target replacement must fail closed"); + + assert!( + format!("{error:#}").contains("changed during commit"), + "unexpected error: {error:#}" + ); + assert_eq!(fs::read(tmp.path().join("SOUL.md")).unwrap(), b"old soul"); + assert_eq!( + fs::read(tmp.path().join("IDENTITY.md")).unwrap(), + b"concurrent identity" + ); + assert_eq!(staging_litter(tmp.path()), Vec::::new()); + } + + #[test] + fn approved_recovery_does_not_claim_ownership_of_same_byte_entries() { + let tmp = tempfile::tempdir().unwrap(); + let ward = ward_in(tmp.path()); + fs::write(tmp.path().join("SOUL.md"), b"new soul").unwrap(); + fs::write(tmp.path().join("IDENTITY.md"), b"concurrent identity").unwrap(); + let edits = vec![ + FileEdit::new("SOUL.md", b"new soul".to_vec()), + FileEdit::new("IDENTITY.md", b"new identity".to_vec()), + ]; + let expected = BTreeMap::from([ + ("SOUL.md".to_string(), b"old soul".to_vec()), + ("IDENTITY.md".to_string(), b"old identity".to_vec()), + ]); + + let error = ward + .apply_after_threads_approval( + &edits, + &Authorization::signed_by("SHA256:principal-key"), + &expected, + ) + .expect_err("diverged recovery target must fail the whole batch"); + + assert!( + format!("{error:#}").contains("ownership is unproven"), + "unexpected error: {error:#}" + ); + assert_eq!(fs::read(tmp.path().join("SOUL.md")).unwrap(), b"new soul"); + assert_eq!( + fs::read(tmp.path().join("IDENTITY.md")).unwrap(), + b"concurrent identity" + ); + assert_eq!(staging_litter(tmp.path()), Vec::::new()); + } + + #[test] + fn approved_recovery_revalidates_already_applied_entries_before_finalizing() { + let tmp = tempfile::tempdir().unwrap(); + let ward = ward_in(tmp.path()); + fs::write(tmp.path().join("SOUL.md"), b"new soul").unwrap(); + fs::write(tmp.path().join("IDENTITY.md"), b"old identity").unwrap(); + let edits = vec![ + FileEdit::new("SOUL.md", b"new soul".to_vec()), + FileEdit::new("IDENTITY.md", b"new identity".to_vec()), + ]; + let expected = BTreeMap::from([ + ("SOUL.md".to_string(), b"old soul".to_vec()), + ("IDENTITY.md".to_string(), b"old identity".to_vec()), + ]); + let canonical_home = tmp.path().canonicalize().unwrap(); + set_conditional_write_actions( + canonical_home.join("IDENTITY.md"), + vec![(canonical_home.join("SOUL.md"), b"concurrent soul".to_vec())], + ); + + let error = ward + .apply_after_threads_approval( + &edits, + &Authorization::signed_by("SHA256:principal-key"), + &expected, + ) + .expect_err("already-applied targets must be revalidated"); + + assert!( + format!("{error:#}").contains("changed before batch finalization"), + "unexpected error: {error:#}" + ); + assert_eq!( + fs::read(tmp.path().join("SOUL.md")).unwrap(), + b"concurrent soul" + ); + assert_eq!( + fs::read(tmp.path().join("IDENTITY.md")).unwrap(), + b"old identity" + ); + } + + #[test] + fn approved_rollback_preserves_bytes_changed_during_rollback() { + let tmp = tempfile::tempdir().unwrap(); + let ward = ward_in(tmp.path()); + fs::write(tmp.path().join("SOUL.md"), b"old soul").unwrap(); + fs::write(tmp.path().join("IDENTITY.md"), b"old identity").unwrap(); + let edits = vec![ + FileEdit::new("SOUL.md", b"new soul".to_vec()), + FileEdit::new("IDENTITY.md", b"new identity".to_vec()), + ]; + let expected = BTreeMap::from([ + ("SOUL.md".to_string(), b"old soul".to_vec()), + ("IDENTITY.md".to_string(), b"old identity".to_vec()), + ]); + let canonical_home = tmp.path().canonicalize().unwrap(); + set_conditional_write_actions( + canonical_home.join("IDENTITY.md"), + vec![ + (canonical_home.join("SOUL.md"), b"concurrent soul".to_vec()), + ( + canonical_home.join("IDENTITY.md"), + b"concurrent identity".to_vec(), + ), + ], + ); + + let error = ward + .apply_after_threads_approval( + &edits, + &Authorization::signed_by("SHA256:principal-key"), + &expected, + ) + .expect_err("rollback must not overwrite a concurrent target"); + + assert!( + format!("{error:#}").contains("conditional rollback also failed"), + "unexpected error: {error:#}" + ); + assert_eq!( + fs::read(tmp.path().join("SOUL.md")).unwrap(), + b"concurrent soul" + ); + assert_eq!( + fs::read(tmp.path().join("IDENTITY.md")).unwrap(), + b"concurrent identity" + ); + } + #[test] fn apply_confines_writes_within_home_gate2() { let tmp = tempfile::tempdir().unwrap(); From 6564519e01263b547cd9f30db6d9f5d651fdf926 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:53:56 -0500 Subject: [PATCH 4/7] fix(ward): bind decisions to proposal revisions Expose a daemon-issued authority-envelope revision and canonical familiar UUID, then require manual Phase 5 decisions to carry the inspected revision through durable claim recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/coven-cli/src/api.rs | 298 ++++++++++++++++++++++++++++++------ 1 file changed, 250 insertions(+), 48 deletions(-) diff --git a/crates/coven-cli/src/api.rs b/crates/coven-cli/src/api.rs index a47e25be..d46028ae 100644 --- a/crates/coven-cli/src/api.rs +++ b/crates/coven-cli/src/api.rs @@ -2999,9 +2999,11 @@ fn threads_proposals_response(coven_home: &Path, id: Option<&str>) -> Result Result { + let bytes = + serde_json::to_vec(authority_value).context("serializing proposal revision authority")?; + Ok(blake3::hash(&bytes).to_hex().to_string()) +} + fn ward_tier_number(tier: ward::Tier) -> u8 { match tier { ward::Tier::Protected => 0, @@ -3282,6 +3290,25 @@ pub(crate) fn decide_threads_proposal( proposal_id: &str, decision: &str, body: Option<&str>, +) -> Result { + decide_threads_proposal_inner(coven_home, proposal_id, decision, body, true) +} + +fn decide_threads_proposal_automatic( + coven_home: &Path, + proposal_id: &str, + decision: &str, + body: Option<&str>, +) -> Result { + decide_threads_proposal_inner(coven_home, proposal_id, decision, body, false) +} + +fn decide_threads_proposal_inner( + coven_home: &Path, + proposal_id: &str, + decision: &str, + body: Option<&str>, + revision_required: bool, ) -> Result { let _decision_guard = proposal_decision_lock() .lock() @@ -3295,16 +3322,29 @@ pub(crate) fn decide_threads_proposal( ) } }; - let note = match parse_body(body) { - Ok(payload) => match payload.get("note") { - None | Some(Value::Null) => None, - Some(Value::String(note)) => Some(note.clone()), - Some(_) => { - return json_response(400, &json!({ "blocked": true, "why": "invalid-note" })) - } - }, + let payload = match parse_body(body) { + Ok(payload) => payload, Err(_) => return json_response(400, &json!({ "blocked": true, "why": "invalid-json" })), }; + let note = match payload.get("note") { + None | Some(Value::Null) => None, + Some(Value::String(note)) => Some(note.clone()), + Some(_) => return json_response(400, &json!({ "blocked": true, "why": "invalid-note" })), + }; + let expected_revision = match payload.get("expectedRevision") { + None | Some(Value::Null) => None, + Some(Value::String(revision)) + if revision.len() == 64 && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) => + { + Some(revision.to_ascii_lowercase()) + } + Some(_) => { + return json_response( + 400, + &json!({ "blocked": true, "why": "invalid-proposal-revision" }), + ) + } + }; let conn = store::open_store(&store_path(coven_home))?; if let Some(terminal) = proposal_terminal_event(&conn, proposal_id)? { let matches_request = matches!( @@ -3335,30 +3375,42 @@ pub(crate) fn decide_threads_proposal( }), ); } - let mut claim = - match PendingDecisionClaim::acquire(coven_home, proposal_uuid, decision, note.as_deref()) { - Ok(Some(claim)) => claim, - Ok(None) => { - return json_response( - 404, - &json!({ "blocked": true, "why": "proposal-not-found" }), - ) - } - Err(error) - if error - .chain() - .any(|cause| cause.downcast_ref::().is_some()) => - { - return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })) - } - Err(error) if error.to_string().contains("already claimed") => { - return json_response( - 409, - &json!({ "blocked": true, "why": "proposal-decision-in-progress" }), - ) - } - Err(error) => return Err(error), - }; + let mut claim = match PendingDecisionClaim::acquire( + coven_home, + proposal_uuid, + decision, + note.as_deref(), + expected_revision.as_deref(), + revision_required, + ) { + Ok(Some(claim)) => claim, + Ok(None) => { + return json_response( + 404, + &json!({ "blocked": true, "why": "proposal-not-found" }), + ) + } + Err(error) + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) => + { + return json_response(409, &json!({ "blocked": true, "why": "proposal-corrupt" })) + } + Err(error) if error.to_string().contains("already claimed") => { + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-decision-in-progress" }), + ) + } + Err(error) if error.downcast_ref::().is_some() => { + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-revision-mismatch" }), + ) + } + Err(error) => return Err(error), + }; if claim.recovery { claim.preserve(); } @@ -3391,6 +3443,12 @@ pub(crate) fn decide_threads_proposal( &json!({ "blocked": true, "why": "proposal-recovery-request-conflict" }), ); } + if expected_revision.is_some() && expected_revision != request.expected_revision { + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-recovery-request-conflict" }), + ); + } } let note = durable_request .as_ref() @@ -3406,6 +3464,31 @@ pub(crate) fn decide_threads_proposal( object.remove("decisionRequest"); } let phase5_shape = is_phase5_proposal_shape(&authority_value); + let actual_revision = proposal_revision(&authority_value)?; + if let Some(request) = durable_request.as_ref() { + if request + .expected_revision + .as_ref() + .is_some_and(|expected| expected != &actual_revision) + { + if applying_state.is_none() { + claim.restore_pending(&mut raw_value)?; + } + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-revision-mismatch" }), + ); + } + if phase5_shape && request.revision_required && request.expected_revision.is_none() { + if applying_state.is_none() { + claim.restore_pending(&mut raw_value)?; + } + return json_response( + 409, + &json!({ "blocked": true, "why": "proposal-revision-required" }), + ); + } + } let scheduled = if phase5_shape { match serde_json::from_value::( authority_value.clone(), @@ -4047,7 +4130,7 @@ pub(crate) fn process_due_threads_proposals(coven_home: &Path) -> Result let body = request .rationale .map(|note| json!({ "note": note }).to_string()); - let response = decide_threads_proposal( + let response = decide_threads_proposal_automatic( coven_home, &proposal.pending().id.0.to_string(), &request.decision, @@ -4068,7 +4151,7 @@ pub(crate) fn process_due_threads_proposals(coven_home: &Path) -> Result if !due { return Ok(false); } - let response = decide_threads_proposal( + let response = decide_threads_proposal_automatic( coven_home, &proposal.pending().id.0.to_string(), "approve", @@ -4127,7 +4210,7 @@ fn recover_proposal_claim(coven_home: &Path, claim_path: &Path) -> Result .and_then(|state| state.rationale) .or_else(|| request.and_then(|request| request.rationale)) .map(|note| json!({ "note": note }).to_string()); - let response = decide_threads_proposal( + let response = decide_threads_proposal_automatic( coven_home, &proposal_id.to_string(), decision, @@ -4229,12 +4312,25 @@ struct PendingDecisionClaim { recovery: bool, } +#[derive(Debug)] +struct ProposalRevisionMismatch; + +impl std::fmt::Display for ProposalRevisionMismatch { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("proposal revision does not match inspected revision") + } +} + +impl std::error::Error for ProposalRevisionMismatch {} + impl PendingDecisionClaim { fn acquire( coven_home: &Path, proposal_id: Uuid, decision: &str, rationale: Option<&str>, + expected_revision: Option<&str>, + revision_required: bool, ) -> Result> { if let Some((path, claimed_decision)) = find_any_pending_decision_claim(coven_home, &proposal_id.to_string()) @@ -4271,6 +4367,16 @@ impl PendingDecisionClaim { let mut value: Value = serde_json::from_str(&raw).context("parsing pending proposal before decision claim")?; if proposal_decision_request(&value)?.is_none() { + let mut authority_value = value.clone(); + if let Some(object) = authority_value.as_object_mut() { + object.remove("decisionState"); + object.remove("decisionRequest"); + } + if let Some(expected_revision) = expected_revision { + if proposal_revision(&authority_value)? != expected_revision { + return Err(ProposalRevisionMismatch.into()); + } + } value .as_object_mut() .context("pending proposal must be a JSON object")? @@ -4280,6 +4386,8 @@ impl PendingDecisionClaim { decision: decision.to_string(), rationale: rationale.map(str::to_string), claimed_at: time::OffsetDateTime::now_utc(), + expected_revision: expected_revision.map(str::to_string), + revision_required, })?, ); } @@ -4346,6 +4454,10 @@ struct ProposalDecisionRequest { decision: String, rationale: Option, claimed_at: time::OffsetDateTime, + #[serde(default)] + expected_revision: Option, + #[serde(default)] + revision_required: bool, } fn proposal_decision_request(raw_value: &Value) -> Result> { @@ -8242,6 +8354,8 @@ tier = 2 let listed = &body["proposals"][0]; assert_eq!(listed["proposalId"], proposal_id.to_string()); assert_eq!(listed["familiarId"], "sage"); + assert_eq!(listed["familiarUuid"], familiar_id.0.to_string()); + assert_eq!(listed["proposalRevision"].as_str().map(str::len), Some(64)); assert_eq!(listed["approvalPath"]["variant"], "familiar_coherence"); assert_eq!(listed["approvalPath"]["label"], "familiar_review"); assert_eq!( @@ -8906,6 +9020,30 @@ tier = 0 Ok((path, proposal_id.to_string())) } + fn scheduled_decision_body( + home: &Path, + proposal_id: &str, + note: Option<&str>, + ) -> Result { + let listed = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + let body: Value = serde_json::from_str(&listed.body)?; + let proposal = body["proposals"] + .as_array() + .and_then(|proposals| { + proposals + .iter() + .find(|proposal| proposal["proposalId"] == proposal_id) + }) + .context("scheduled proposal is listed")?; + let revision = proposal["proposalRevision"] + .as_str() + .context("scheduled proposal carries a revision")?; + Ok(match note { + Some(note) => json!({ "note": note, "expectedRevision": revision }).to_string(), + None => json!({ "expectedRevision": revision }).to_string(), + }) + } + #[test] fn threads_scheduled_human_required_enforces_rationale_and_applies() -> Result<()> { let temp = tempfile::tempdir()?; @@ -8915,13 +9053,14 @@ tier = 0 coven_threads_core::ApprovalPath::HumanApprovalWithRationale, time::OffsetDateTime::now_utc(), )?; + let missing_rationale_body = scheduled_decision_body(home, &proposal_id, None)?; let missing_rationale = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some("{}"), + Some(&missing_rationale_body), )?; assert_eq!( missing_rationale.status, 409, @@ -8932,12 +9071,14 @@ tier = 0 assert_eq!(body["why"], "proposal-rationale-required"); assert!(pending.exists()); + let approved_body = + scheduled_decision_body(home, &proposal_id, Some("reviewed semantic change"))?; let approved = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"note":"reviewed semantic change"}"#), + Some(&approved_body), )?; assert_eq!(approved.status, 200, "got {}", approved.body); assert_eq!( @@ -8962,6 +9103,54 @@ tier = 0 Ok(()) } + #[test] + fn threads_scheduled_manual_decision_requires_matching_revision() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let (pending, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApproval, + time::OffsetDateTime::now_utc(), + )?; + + let missing = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), + )?; + assert_eq!(missing.status, 409); + assert!(missing.body.contains("proposal-revision-required")); + assert!(pending.exists()); + + let mismatch = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some(&json!({ "expectedRevision": "0".repeat(64) }).to_string()), + )?; + assert_eq!(mismatch.status, 409); + assert!(mismatch.body.contains("proposal-revision-mismatch")); + assert!(pending.exists()); + + let listed = handle_request("GET", "/api/v1/threads/proposals", home, None)?; + let body: Value = serde_json::from_str(&listed.body)?; + let revision = body["proposals"][0]["proposalRevision"] + .as_str() + .context("proposal list carries revision")?; + let approved = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some(&json!({ "expectedRevision": revision }).to_string()), + )?; + assert_eq!(approved.status, 200, "got {}", approved.body); + Ok(()) + } + #[test] fn threads_scheduled_audits_preserve_committed_channel() -> Result<()> { let temp = tempfile::tempdir()?; @@ -8972,13 +9161,14 @@ tier = 0 time::OffsetDateTime::now_utc(), coven_threads_core::Channel::Serialization, )?; + let decision_body = scheduled_decision_body(home, &proposal_id, None)?; let approved = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some("{}"), + Some(&decision_body), )?; assert_eq!(approved.status, 200, "got {}", approved.body); let conn = store::open_store(&home.join("coven.sqlite3"))?; @@ -9035,13 +9225,14 @@ tier = 0 coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, time::OffsetDateTime::now_utc(), )?; + let premature_body = scheduled_decision_body(home, &proposal_id, None)?; let premature = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some("{}"), + Some(&premature_body), )?; assert_eq!(premature.status, 409, "got {}", premature.body); let body: Value = serde_json::from_str(&premature.body)?; @@ -9051,12 +9242,13 @@ tier = 0 "before" ); + let veto_body = scheduled_decision_body(home, &proposal_id, Some("familiar objected"))?; let vetoed = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/reject"), home, None, - Some(r#"{"note":"familiar objected"}"#), + Some(&veto_body), )?; assert_eq!(vetoed.status, 200, "got {}", vetoed.body); let body: Value = serde_json::from_str(&vetoed.body)?; @@ -9090,13 +9282,14 @@ tier = 0 coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, time::OffsetDateTime::now_utc() - time::Duration::minutes(10), )?; + let decision_body = scheduled_decision_body(home, &proposal_id, None)?; let approved = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some("{}"), + Some(&decision_body), )?; assert_eq!(approved.status, 200, "got {}", approved.body); @@ -9133,13 +9326,14 @@ tier = 0 )?; let target = home.join("familiars/sage/reviewed/skill.md"); std::fs::write(&target, "concurrent")?; + let decision_body = scheduled_decision_body(home, &proposal_id, None)?; let response = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some("{}"), + Some(&decision_body), )?; assert_eq!(response.status, 409, "got {}", response.body); @@ -9178,13 +9372,14 @@ tier = 0 "path = \"reviewed/skill.md\"\ntier = 0", ); std::fs::write(&ward_path, ward)?; + let decision_body = scheduled_decision_body(home, &proposal_id, None)?; let response = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some("{}"), + Some(&decision_body), )?; assert_eq!(response.status, 409, "got {}", response.body); @@ -9211,12 +9406,13 @@ tier = 0 ProposalDecisionFailpoint::ApplyBeforeAudit, proposal_id.clone(), ))); + let decision_body = scheduled_decision_body(home, &proposal_id, Some("durable rationale"))?; let interrupted = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"note":"durable rationale"}"#), + Some(&decision_body), ); assert!(interrupted.is_err()); @@ -9339,12 +9535,14 @@ tier = 0 ProposalDecisionFailpoint::ApplyBeforeAudit, proposal_id.clone(), ))); + let decision_body = + scheduled_decision_body(home, &proposal_id, Some("scheduler recovery"))?; let interrupted = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"note":"scheduler recovery"}"#), + Some(&decision_body), ); assert!(interrupted.is_err()); @@ -9376,12 +9574,14 @@ tier = 0 ProposalDecisionFailpoint::ClaimBeforeValidation, proposal_id.clone(), ))); + let decision_body = + scheduled_decision_body(home, &proposal_id, Some("claim-time rationale"))?; let interrupted = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"note":"claim-time rationale"}"#), + Some(&decision_body), ); assert!(interrupted.is_err()); @@ -9417,12 +9617,13 @@ tier = 0 ProposalDecisionFailpoint::ClaimBeforeValidation, proposal_id.clone(), ))); + let decision_body = scheduled_decision_body(home, &proposal_id, Some("timely veto"))?; let interrupted = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/reject"), home, None, - Some(r#"{"note":"timely veto"}"#), + Some(&decision_body), ); assert!(interrupted.is_err()); std::thread::sleep(std::time::Duration::from_millis(2_100)); @@ -9452,13 +9653,14 @@ tier = 0 time::OffsetDateTime::now_utc(), coven_threads_core::Channel::Mutation, )?; + let decision_body = scheduled_decision_body(home, &proposal_id, None)?; let rejected = handle_request_with_body( "POST", &format!("/api/v1/threads/proposals/{proposal_id}/reject"), home, None, - Some("{}"), + Some(&decision_body), )?; assert_eq!(rejected.status, 409); assert!(rejected.body.contains("proposal-not-human-decidable")); From b704acac6bfc2474dd33b1d91a5e0a379297ed99 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:57:12 -0500 Subject: [PATCH 5/7] fix(ward): canonicalize proposal revision hashes Use recursively key-sorted compact JSON and SHA-256 so Cave can independently bind displayed staged contents to the daemon revision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/coven-cli/src/api.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/coven-cli/src/api.rs b/crates/coven-cli/src/api.rs index d46028ae..0f4642f9 100644 --- a/crates/coven-cli/src/api.rs +++ b/crates/coven-cli/src/api.rs @@ -3271,9 +3271,24 @@ fn revalidate_scheduled_materialized_before( } fn proposal_revision(authority_value: &Value) -> Result { - let bytes = - serde_json::to_vec(authority_value).context("serializing proposal revision authority")?; - Ok(blake3::hash(&bytes).to_hex().to_string()) + fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.iter().map(canonicalize).collect()), + Value::Object(values) => { + let sorted: std::collections::BTreeMap<_, _> = values + .iter() + .map(|(key, value)| (key.clone(), canonicalize(value))) + .collect(); + serde_json::to_value(sorted).expect("canonical JSON map is serializable") + } + value => value.clone(), + } + } + + let bytes = serde_json::to_vec(&canonicalize(authority_value)) + .context("serializing canonical proposal revision authority")?; + let digest = Sha256::digest(bytes); + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) } fn ward_tier_number(tier: ward::Tier) -> u8 { From e504fcfceab3e129ffade3cccc1c1e2be679bbf6 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:55:05 -0500 Subject: [PATCH 6/7] fix(ward): support atomic approved writes on Windows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/coven-cli/Cargo.toml | 2 +- crates/coven-cli/src/ward.rs | 376 ++++++++++++++++++++++++++++++----- 2 files changed, 324 insertions(+), 54 deletions(-) diff --git a/crates/coven-cli/Cargo.toml b/crates/coven-cli/Cargo.toml index c8bb3462..ac8d00aa 100644 --- a/crates/coven-cli/Cargo.toml +++ b/crates/coven-cli/Cargo.toml @@ -54,7 +54,7 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] interprocess = "2" widestring = "1" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_SystemInformation"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_SystemInformation"] } [dev-dependencies] tempfile = "3" diff --git a/crates/coven-cli/src/ward.rs b/crates/coven-cli/src/ward.rs index 3290913a..0e16747e 100644 --- a/crates/coven-cli/src/ward.rs +++ b/crates/coven-cli/src/ward.rs @@ -57,6 +57,13 @@ //! - **Hard-linked targets** are harmless by construction: `rename` replaces //! the directory entry and never writes through the linked inode. //! +//! Phase 5 approved writes additionally preserve the file actually displaced by +//! the atomic commit. Linux and macOS exchange the staged and target entries; +//! Windows uses `ReplaceFileW` with a distinct randomized sibling backup. The +//! displaced before-image and installed bytes are verified before the batch can +//! finalize, and rollback uses the same primitive in reverse so concurrent +//! target bytes are preserved rather than overwritten. +//! //! Residual risk (accepted): a same-privilege process that can already write //! inside the familiar home can still swap path components in the window //! between the per-component verification and the final `rename`. The check @@ -73,6 +80,8 @@ use std::fs::OpenOptions; use std::io::{ErrorKind, Write}; #[cfg(any(target_os = "linux", target_os = "macos"))] use std::os::unix::ffi::OsStrExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; @@ -857,9 +866,21 @@ fn write_atomic( Ok(audit) } +struct ApprovedWritePaths { + staged: PathBuf, + displaced: PathBuf, +} + +impl ApprovedWritePaths { + fn new(target: &Path, staged: PathBuf) -> Result { + let displaced = approved_write_displaced_path(target, &staged)?; + Ok(Self { staged, displaced }) + } +} + struct PreparedConditionalWrite { path: PathBuf, - staged: Option, + paths: Option, already_applied: bool, expected_before: Vec, new_contents: Vec, @@ -869,9 +890,10 @@ struct PreparedConditionalWrite { /// Commit an approved proposal only while every target still has the exact /// before-image reviewed by the scheduler. /// -/// Existing targets are exchanged atomically with randomized sibling staging -/// files. The displaced inode is then compared with the approved before-image; -/// a mismatch swaps it back and rolls back every earlier edit in the batch. +/// Existing targets are atomically replaced from randomized sibling staging +/// files while preserving the displaced target at a known sibling path. The +/// displaced bytes are then compared with the approved before-image; a mismatch +/// restores them and rolls back every earlier edit owned by this apply attempt. /// Already-applied bytes are accepted so crash recovery remains idempotent. fn write_atomically_if_unchanged( canonical_home: &Path, @@ -907,7 +929,7 @@ fn write_atomically_if_unchanged( } Ok(PreparedConditionalWrite { path, - staged: None, + paths: None, already_applied, expected_before: expected, new_contents: edit.new_contents.clone(), @@ -932,20 +954,25 @@ fn write_atomically_if_unchanged( continue; } match stage_contents(&prepared[index].path, &prepared[index].new_contents) { - Ok(staged) => prepared[index].staged = Some(staged), + Ok(paths) => prepared[index].paths = Some(paths), Err(error) => return fail_after_conditional_rollback(&prepared, &[], error), } } let mut swapped = Vec::new(); for (index, write) in prepared.iter().enumerate() { - let Some(staged) = &write.staged else { + let Some(paths) = &write.paths else { continue; }; if let Err(error) = maybe_run_conditional_write_hook(&write.path) { return fail_after_conditional_rollback(&prepared, &swapped, error); } - if let Err(error) = atomic_exchange(staged, &write.path) { + if let Err(error) = + atomic_replace_preserving_target(&write.path, &paths.staged, &paths.displaced) + { + if failed_replace_displaced_target(&paths.displaced) { + swapped.push(index); + } return fail_after_conditional_rollback( &prepared, &swapped, @@ -958,8 +985,9 @@ fn write_atomically_if_unchanged( swapped.push(index); let verification = (|| -> Result<()> { - let displaced = std::fs::read(staged) - .with_context(|| format!("reading displaced target {}", staged.display()))?; + let displaced = std::fs::read(&paths.displaced).with_context(|| { + format!("reading displaced target {}", paths.displaced.display()) + })?; let installed = std::fs::read(&write.path) .with_context(|| format!("verifying approved write {}", write.path.display()))?; if displaced != write.expected_before || installed != write.new_contents { @@ -994,9 +1022,13 @@ fn write_atomically_if_unchanged( } for write in &prepared { - if let Some(staged) = &write.staged { - std::fs::remove_file(staged) - .with_context(|| format!("removing approved-write backup {}", staged.display()))?; + if let Some(paths) = &write.paths { + std::fs::remove_file(&paths.displaced).with_context(|| { + format!( + "removing approved-write backup {}", + paths.displaced.display() + ) + })?; } } @@ -1027,11 +1059,11 @@ fn rollback_conditional_writes( let mut errors = Vec::new(); for &index in swapped.iter().rev() { let write = &prepared[index]; - let staged = write - .staged + let paths = write + .paths .as_ref() - .context("swapped conditional write has no staging path")?; - if let Err(error) = restore_swapped_write(write, staged) { + .context("swapped conditional write has no approved-write paths")?; + if let Err(error) = restore_swapped_write(write, paths) { errors.push(format!("{}: {error:#}", write.decision.target)); } } @@ -1068,25 +1100,31 @@ fn fail_after_conditional_rollback( } } -fn restore_swapped_write(write: &PreparedConditionalWrite, staged: &Path) -> Result<()> { - let restore_bytes = std::fs::read(staged) - .with_context(|| format!("reading rollback source {}", staged.display()))?; - atomic_exchange(staged, &write.path).with_context(|| { - format!( - "restoring approved target {} from {}", - write.path.display(), - staged.display() - ) - })?; - let verification = verify_rollback_exchange(write, staged, &restore_bytes); - if let Err(error) = verification { - atomic_exchange(staged, &write.path).with_context(|| { +fn restore_swapped_write( + write: &PreparedConditionalWrite, + paths: &ApprovedWritePaths, +) -> Result<()> { + let restore_bytes = std::fs::read(&paths.displaced) + .with_context(|| format!("reading rollback source {}", paths.displaced.display()))?; + atomic_replace_preserving_target(&write.path, &paths.displaced, &paths.staged).with_context( + || { format!( - "putting concurrent bytes back at {} after rollback verification failed: \ - {error:#}", - write.path.display() + "restoring approved target {} from {}", + write.path.display(), + paths.displaced.display() ) - })?; + }, + )?; + let verification = verify_rollback_exchange(write, &paths.staged, &restore_bytes); + if let Err(error) = verification { + atomic_replace_preserving_target(&write.path, &paths.staged, &paths.displaced) + .with_context(|| { + format!( + "putting concurrent bytes back at {} after rollback verification failed: \ + {error:#}", + write.path.display() + ) + })?; return Err(error); } Ok(()) @@ -1116,17 +1154,20 @@ fn verify_rollback_exchange( fn cleanup_conditional_staging(prepared: &[PreparedConditionalWrite]) { for write in prepared { - if let Some(staged) = &write.staged { - let _ = std::fs::remove_file(staged); + if let Some(paths) = &write.paths { + let _ = std::fs::remove_file(&paths.staged); + if paths.displaced != paths.staged { + let _ = std::fs::remove_file(&paths.displaced); + } } } } pub(crate) const fn supports_atomic_approved_writes() -> bool { - cfg!(any(target_os = "linux", target_os = "macos")) + cfg!(any(target_os = "linux", target_os = "macos", windows)) } -fn stage_contents(path: &Path, contents: &[u8]) -> Result { +fn stage_contents(path: &Path, contents: &[u8]) -> Result { let (staged, mut file) = create_staging_file(path)?; let result = (|| -> Result<()> { file.write_all(contents) @@ -1140,7 +1181,13 @@ fn stage_contents(path: &Path, contents: &[u8]) -> Result { let _ = std::fs::remove_file(&staged); return Err(error); } - Ok(staged) + match ApprovedWritePaths::new(path, staged.clone()) { + Ok(paths) => Ok(paths), + Err(error) => { + let _ = std::fs::remove_file(staged); + Err(error) + } + } } #[cfg(test)] @@ -1189,7 +1236,14 @@ fn maybe_run_conditional_write_hook(_path: &Path) -> Result<()> { } #[cfg(target_os = "macos")] -fn atomic_exchange(left: &Path, right: &Path) -> Result<()> { +fn atomic_replace_preserving_target( + target: &Path, + replacement: &Path, + displaced: &Path, +) -> Result<()> { + if replacement != displaced { + bail!("macOS approved-write exchange requires a shared replacement/backup path"); + } const RENAME_SWAP: u32 = 0x0000_0002; unsafe extern "C" { fn renamex_np( @@ -1199,10 +1253,11 @@ fn atomic_exchange(left: &Path, right: &Path) -> Result<()> { ) -> libc::c_int; } - let left = CString::new(left.as_os_str().as_bytes()).context("staging path contains NUL")?; - let right = CString::new(right.as_os_str().as_bytes()).context("target path contains NUL")?; + let replacement = CString::new(replacement.as_os_str().as_bytes()) + .context("replacement path contains NUL")?; + let target = CString::new(target.as_os_str().as_bytes()).context("target path contains NUL")?; // SAFETY: both C strings are NUL-terminated and remain alive for the call. - let result = unsafe { renamex_np(left.as_ptr(), right.as_ptr(), RENAME_SWAP) }; + let result = unsafe { renamex_np(replacement.as_ptr(), target.as_ptr(), RENAME_SWAP) }; if result == 0 { Ok(()) } else { @@ -1211,18 +1266,26 @@ fn atomic_exchange(left: &Path, right: &Path) -> Result<()> { } #[cfg(target_os = "linux")] -fn atomic_exchange(left: &Path, right: &Path) -> Result<()> { +fn atomic_replace_preserving_target( + target: &Path, + replacement: &Path, + displaced: &Path, +) -> Result<()> { + if replacement != displaced { + bail!("Linux approved-write exchange requires a shared replacement/backup path"); + } const RENAME_EXCHANGE: libc::c_uint = 1 << 1; - let left = CString::new(left.as_os_str().as_bytes()).context("staging path contains NUL")?; - let right = CString::new(right.as_os_str().as_bytes()).context("target path contains NUL")?; + let replacement = CString::new(replacement.as_os_str().as_bytes()) + .context("replacement path contains NUL")?; + let target = CString::new(target.as_os_str().as_bytes()).context("target path contains NUL")?; // SAFETY: both C strings are NUL-terminated and remain alive for the syscall. let result = unsafe { libc::syscall( libc::SYS_renameat2, libc::AT_FDCWD, - left.as_ptr(), + replacement.as_ptr(), libc::AT_FDCWD, - right.as_ptr(), + target.as_ptr(), RENAME_EXCHANGE, ) }; @@ -1233,11 +1296,136 @@ fn atomic_exchange(left: &Path, right: &Path) -> Result<()> { } } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -fn atomic_exchange(_left: &Path, _right: &Path) -> Result<()> { +#[cfg(windows)] +fn atomic_replace_preserving_target( + target: &Path, + replacement: &Path, + displaced: &Path, +) -> Result<()> { + use windows_sys::Win32::Foundation::ERROR_UNABLE_TO_MOVE_REPLACEMENT_2; + use windows_sys::Win32::Storage::FileSystem::ReplaceFileW; + + let target_wide = windows_path(target, "target")?; + let replacement_wide = windows_path(replacement, "replacement")?; + let displaced_wide = windows_path(displaced, "displaced")?; + // REPLACEFILE_WRITE_THROUGH is documented as unsupported. The staged file + // is synced before this call, and no ignore flags are used. + let result = unsafe { + ReplaceFileW( + target_wide.as_ptr(), + replacement_wide.as_ptr(), + displaced_wide.as_ptr(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + if result != 0 { + Ok(()) + } else { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 as i32) { + if let Err(recovery_error) = + restore_displaced_target_after_partial_replace(target, displaced) + { + return Err(anyhow!( + "atomically replacing approved target failed: {error}; restoring the \ + documented partial replacement also failed: {recovery_error:#}" + )); + } + } + Err(error).context("atomically replacing approved target while preserving displaced bytes") + } +} + +#[cfg(windows)] +fn windows_path(path: &Path, label: &str) -> Result> { + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + if wide.contains(&0) { + bail!("{label} path contains NUL"); + } + wide.push(0); + Ok(wide) +} + +#[cfg(windows)] +fn restore_displaced_target_after_partial_replace(target: &Path, displaced: &Path) -> Result<()> { + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; + + let target = windows_path(target, "target")?; + let displaced = windows_path(displaced, "displaced")?; + let result = + unsafe { MoveFileExW(displaced.as_ptr(), target.as_ptr(), MOVEFILE_WRITE_THROUGH) }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + .context("atomically restoring the target after a partial Windows file replacement") + } +} + +#[cfg(windows)] +fn failed_replace_displaced_target(displaced: &Path) -> bool { + match std::fs::symlink_metadata(displaced) { + Ok(_) => true, + Err(error) if error.kind() == ErrorKind::NotFound => false, + Err(_) => true, + } +} + +#[cfg(not(windows))] +fn failed_replace_displaced_target(_displaced: &Path) -> bool { + false +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn atomic_replace_preserving_target( + _target: &Path, + _replacement: &Path, + _displaced: &Path, +) -> Result<()> { bail!("atomic approved-write exchange is unsupported on this platform") } +#[cfg(windows)] +fn approved_write_displaced_path(target: &Path, _staged: &Path) -> Result { + // Use an independent random name so observing the staged entry does not + // disclose the backup path before the atomic ReplaceFileW call. + for _ in 0..16 { + let displaced = displaced_path(target); + match std::fs::symlink_metadata(&displaced) { + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(displaced), + Ok(_) => continue, + Err(err) => { + return Err(err).with_context(|| { + format!( + "checking approved-write backup path {}", + displaced.display() + ) + }) + } + } + } + bail!( + "could not select a fresh approved-write backup beside {}", + target.display() + ) +} + +#[cfg(not(windows))] +fn approved_write_displaced_path(_target: &Path, staged: &Path) -> Result { + Ok(staged.to_path_buf()) +} + +#[cfg(windows)] +fn displaced_path(path: &Path) -> PathBuf { + let name = path + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_default(); + path.with_file_name(format!(".{name}.ward-displaced-{}", uuid::Uuid::new_v4())) +} + /// Create a fresh sibling staging file for an atomic write. /// /// The staging path is intentionally unpredictable and opened with @@ -1452,6 +1640,9 @@ mod tests { use super::*; use std::fs; + #[cfg(windows)] + const _: () = assert!(supports_atomic_approved_writes()); + fn sample_config() -> WardConfig { WardConfig { principal_key_fingerprint: "SHA256:principal-key".to_string(), @@ -1490,19 +1681,98 @@ mod tests { Ward::new(dir.to_path_buf(), sample_config()).expect("valid ward") } - /// Directory entries that look like staging files (randomized names make - /// exact-path existence checks meaningless — scan for the marker instead). + /// Directory entries that look like approved-write working files (randomized + /// names make exact-path existence checks meaningless). fn staging_litter(dir: &Path) -> Vec { match fs::read_dir(dir) { Ok(entries) => entries .filter_map(|entry| entry.ok()) .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .filter(|name| name.contains(".ward-staged")) + .filter(|name| name.contains(".ward-staged") || name.contains(".ward-displaced")) .collect(), Err(_) => Vec::new(), } } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn unix_approved_write_paths_reuse_staging_path_for_exchange() { + let staged = PathBuf::from(".SOUL.md.ward-staged-test"); + let paths = ApprovedWritePaths::new(Path::new("SOUL.md"), staged.clone()).unwrap(); + + assert_eq!(paths.staged, staged); + assert_eq!(paths.displaced, staged); + } + + #[cfg(windows)] + #[test] + fn windows_approved_write_paths_select_distinct_displaced_sibling() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("SOUL.md"); + let staged = tmp.path().join(".SOUL.md.ward-staged-test"); + let paths = ApprovedWritePaths::new(&target, staged.clone()).unwrap(); + + assert_eq!(paths.staged, staged); + assert_ne!(paths.displaced, paths.staged); + assert_eq!(paths.displaced.parent(), target.parent()); + assert!(paths + .displaced + .file_name() + .unwrap() + .to_string_lossy() + .contains(".ward-displaced-")); + assert!(!paths.displaced.exists()); + } + + #[cfg(windows)] + #[test] + fn windows_atomic_replace_preserves_displaced_bytes_for_commit_and_rollback() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("SOUL.md"); + let staged = tmp.path().join(".SOUL.md.ward-staged-test"); + fs::write(&target, b"old soul").unwrap(); + fs::write(&staged, b"new soul").unwrap(); + let paths = ApprovedWritePaths::new(&target, staged).unwrap(); + + atomic_replace_preserving_target(&target, &paths.staged, &paths.displaced).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new soul"); + assert_eq!(fs::read(&paths.displaced).unwrap(), b"old soul"); + assert!(!paths.staged.exists()); + + atomic_replace_preserving_target(&target, &paths.displaced, &paths.staged).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"old soul"); + assert_eq!(fs::read(&paths.staged).unwrap(), b"new soul"); + assert!(!paths.displaced.exists()); + } + + #[cfg(windows)] + #[test] + fn windows_partial_replace_state_requires_rollback_before_cleanup() { + let tmp = tempfile::tempdir().unwrap(); + let displaced = tmp.path().join(".SOUL.md.ward-displaced-test"); + + assert!(!failed_replace_displaced_target(&displaced)); + fs::write(&displaced, b"old soul").unwrap(); + assert!(failed_replace_displaced_target(&displaced)); + } + + #[cfg(windows)] + #[test] + fn windows_partial_replace_recovery_restores_target_without_consuming_staged_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("SOUL.md"); + let staged = tmp.path().join(".SOUL.md.ward-staged-test"); + let displaced = tmp.path().join(".SOUL.md.ward-displaced-test"); + fs::write(&staged, b"new soul").unwrap(); + fs::write(&displaced, b"old soul").unwrap(); + + restore_displaced_target_after_partial_replace(&target, &displaced).unwrap(); + + assert_eq!(fs::read(&target).unwrap(), b"old soul"); + assert_eq!(fs::read(&staged).unwrap(), b"new soul"); + assert!(!displaced.exists()); + } + #[test] fn parses_ward_toml() { // Root-level scalars/arrays must precede the `[[surface]]` From f04bce07b486a1025926ffc1e10305bc71eecfcf Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:29:08 -0500 Subject: [PATCH 7/7] fix(daemon): ignore scheduler threads during duplicate cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/coven-cli/src/daemon.rs | 70 ++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/crates/coven-cli/src/daemon.rs b/crates/coven-cli/src/daemon.rs index b19f8373..cfa11d5b 100644 --- a/crates/coven-cli/src/daemon.rs +++ b/crates/coven-cli/src/daemon.rs @@ -865,11 +865,14 @@ fn cleanup_unreachable_duplicate_daemons(coven_home: &Path, active_pid: u32) { .processes() .iter() .filter_map(|(pid, process)| { - let pid_u32 = pid.as_u32(); - if pid_u32 == active_pid - || !process_is_coven_daemon_serve(process.cmd()) - || !process_coven_home_matches(process.environ(), coven_home) - { + if !process_is_unreachable_duplicate_daemon_candidate( + pid.as_u32(), + active_pid, + process.thread_kind(), + process.cmd(), + process.environ(), + coven_home, + ) { return None; } Some((*pid, process.start_time(), process.cmd().to_vec())) @@ -883,8 +886,14 @@ fn cleanup_unreachable_duplicate_daemons(coven_home: &Path, active_pid: u32) { }; if process.start_time() != start_time || process.cmd() != cmd.as_slice() - || !process_is_coven_daemon_serve(process.cmd()) - || !process_coven_home_matches(process.environ(), coven_home) + || !process_is_unreachable_duplicate_daemon_candidate( + pid.as_u32(), + active_pid, + process.thread_kind(), + process.cmd(), + process.environ(), + coven_home, + ) { continue; } @@ -909,6 +918,21 @@ fn cleanup_unreachable_duplicate_daemons(coven_home: &Path, active_pid: u32) { } } +#[cfg(unix)] +fn process_is_unreachable_duplicate_daemon_candidate( + pid: u32, + active_pid: u32, + thread_kind: Option, + cmd: &[std::ffi::OsString], + environ: &[std::ffi::OsString], + coven_home: &Path, +) -> bool { + pid != active_pid + && thread_kind.is_none() + && process_is_coven_daemon_serve(cmd) + && process_coven_home_matches(environ, coven_home) +} + #[cfg(unix)] fn process_is_coven_daemon_serve(cmd: &[std::ffi::OsString]) -> bool { cmd.windows(2) @@ -2766,6 +2790,38 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn duplicate_daemon_candidate_rejects_threads_but_accepts_processes() { + use std::ffi::OsString; + use sysinfo::ThreadKind; + + let coven_home = Path::new("/home/coven"); + let cmd = [ + OsString::from("coven"), + OsString::from("daemon"), + OsString::from("serve"), + ]; + let environ = [OsString::from("COVEN_HOME=/home/coven")]; + + assert!(process_is_unreachable_duplicate_daemon_candidate( + 42, 41, None, &cmd, &environ, coven_home, + )); + for thread_kind in [ThreadKind::Userland, ThreadKind::Kernel] { + assert!( + !process_is_unreachable_duplicate_daemon_candidate( + 42, + 41, + Some(thread_kind), + &cmd, + &environ, + coven_home, + ), + "{thread_kind:?} thread must not be a duplicate-daemon candidate" + ); + } + } + #[test] fn seeds_trust_for_new_dir_into_missing_config() -> Result<()> { let home = tempfile::tempdir()?;