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..ac8d00aa 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" @@ -56,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/api.rs b/crates/coven-cli/src/api.rs index 3d4751be..0f4642f9 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 { @@ -2843,23 +2928,59 @@ 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 mut proposal_value = raw_value.clone(); + if let Some(object) = proposal_value.as_object_mut() { + object.remove("decisionRequest"); + object.remove("decisionState"); + } + let phase5_shape = is_phase5_proposal_shape(&proposal_value); + let scheduled = if phase5_shape { + match serde_json::from_value::( + proposal_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::( + proposal_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 +2996,60 @@ 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 { @@ -2969,12 +3136,198 @@ 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 proposal_revision(authority_value: &Value) -> Result { + 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 { + 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 { + 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() + .map_err(|_| anyhow::anyhow!("proposal decision lock poisoned"))?; let proposal_uuid = match Uuid::parse_str(proposal_id) { Ok(uuid) => uuid, Err(_) => { @@ -2984,32 +3337,238 @@ 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 Some(path) = find_pending_proposal(coven_home, proposal_uuid)? else { + 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!( + (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(), + 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(); + } + 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" }), + ); + } + 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() + .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 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(), + ) { + 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, @@ -3036,43 +3595,159 @@ fn decide_threads_proposal( targets: targets.clone(), authorization: authorization.clone(), }); - let conn = store::open_store(&store_path(coven_home))?; - if adjudication.is_blocked() { - let state = crate::threads_gate::build_weave_state( - &conn, - &familiar_id, - &workspace, - &config, - &[], - false, - )?; - append_proposal_refusal_audit( - &conn, - proposal_id, - &familiar_id, - state.weave.weave_hash(), - &pending.writer, - &targets, - )?; + 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( - 409, - &json!({ "blocked": true, "why": "proposal-revalidation-failed" }), + 200, + &json!({ + "ok": true, + "decision": terminal_decision_label(&terminal.event_type), + "proposalId": proposal_id, + "filesTouched": terminal.files_touched, + "idempotent": true, + }), ); } - let gated_targets: Vec = adjudication - .decisions - .iter() - .filter(|d| d.tier == ward::Tier::Protected && !d.verdict.is_blocked()) - .map(|d| d.resolved.clone()) - .collect(); - let state = crate::threads_gate::build_weave_state( - &conn, - &familiar_id, - &workspace, - &config, - &gated_targets, - false, - )?; + 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, + &familiar_id, + &workspace, + &config, + &[], + false, + )?; + append_proposal_refusal_audit( + &conn, + proposal_id, + &familiar_id, + 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" }), + ); + } + let gated_targets: Vec = adjudication + .decisions + .iter() + .filter(|d| { + !d.verdict.is_blocked() && (scheduled.is_some() || d.tier == ward::Tier::Protected) + }) + .map(|d| d.resolved.clone()) + .collect(); + 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, @@ -3081,7 +3756,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" }), @@ -3089,24 +3766,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", + 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, @@ -3114,11 +3797,153 @@ fn decide_threads_proposal( ); } + 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: coven_threads_core::Channel::Mutation, + channel: pending.channel, + identity_context: None, }; let verdict = coven_threads_core::validate_fail_closed(&state.weave, &request); crate::threads_gate::append_audit_row( @@ -3138,6 +3963,7 @@ fn decide_threads_proposal( state.weave.weave_hash(), &pending.writer, &targets, + pending.channel, )?; return json_response( 409, @@ -3151,7 +3977,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, @@ -3160,28 +4021,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", + 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!({ @@ -3217,179 +4085,1053 @@ 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) + claims.sort(); + scheduled.sort(); + + 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() + ), + ), + } + } + + 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_automatic( + 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_automatic( + 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) } -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 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_automatic( + coven_home, + &proposal_id.to_string(), + decision, + body.as_deref(), + )?; + Ok(response.status == 200) } -fn staged_edits_to_ward_edits( - pending: &coven_threads_core::PendingProposal, -) -> Result> { - pending +fn ensure_proposal_window_opened_audit( + coven_home: &Path, + proposal: &crate::proposal_scheduler::ScheduledProposal, +) -> Result<()> { + let (Some(deadline), Some(earliest_close)) = + (proposal.veto_deadline(), proposal.earliest_close()) + else { + return Ok(()); + }; + 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| { - 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() -} + .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_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![ + 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!("{:?}", pending.channel).to_lowercase(), + submitted_at, + decided_at, + ], + ) + .context("appending proposal_window_opened audit")?; + Ok(()) +} -fn append_proposal_refusal_audit( +struct PendingDecisionClaim { + path: PathBuf, + original_path: PathBuf, + preserve: bool, + 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()) + { + 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 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() { + 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")? + .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(), + expected_revision: expected_revision.map(str::to_string), + revision_required, + })?, + ); + } + 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, + })) + } + + fn preserve(&mut self) { + self.preserve = true; + } + + fn consume(&mut self) -> Result<()> { + fs::remove_file(&self.path) + .with_context(|| format!("removing proposal decision claim {}", self.path.display()))?; + self.preserve = true; + Ok(()) + } + + 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(()) + } +} + +impl Drop for PendingDecisionClaim { + fn drop(&mut self) { + if !self.preserve && self.path.exists() { + let _ = fs::rename(&self.path, &self.original_path); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +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> { + raw_value + .get("decisionRequest") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("proposal decision request is corrupt") +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProposalBeforeImage { + target: String, + contents: coven_threads_core::StagedContents, +} + +fn proposal_recovery_commitment( conn: &rusqlite::Connection, - proposal_id: &str, + config: &ward::WardConfig, + authority_value: &Value, familiar_id: &str, - weave_hash: &[u8], - approver: &coven_threads_core::WriterId, - files_touched: &[String], + 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]); + } + }; + } + Ok(hasher.finalize().as_bytes().to_vec()) +} + +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() +} + +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() +} + +fn proposal_expected_after(edits: &[ward::FileEdit]) -> BTreeMap> { + edits + .iter() + .map(|edit| (edit.target.clone(), edit.new_contents.clone())) + .collect() +} + +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() +} + +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() +} + +fn persist_proposal_applying_state( + claim_path: &Path, + raw_value: &mut Value, + state: &ProposalApplyingState, ) -> Result<()> { - append_proposal_decision_audit( - conn, - ProposalDecisionAudit { - event_type: coven_threads_core::AuditEventType::ProposalRejected, - proposal_id, - familiar_id, - weave_hash, - approver: Some(approver), - files_touched, - decision: "proposal-revalidation-failed", - }, - ) + 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) } -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, +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 } -fn append_proposal_decision_audit( +fn proposal_applying_state(raw_value: &Value) -> Result> { + raw_value + .get("decisionState") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("invalid proposal applying state") +} + +fn append_proposal_apply_intent( conn: &rusqlite::Connection, - audit: ProposalDecisionAudit<'_>, + proposal_id: &str, + familiar_id: &str, + approver: &coven_threads_core::WriterId, + files_touched: &[String], + state: &ProposalApplyingState, + channel: coven_threads_core::Channel, ) -> Result<()> { - let files_touched = serde_json::to_string(audit.files_touched)?; + 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, 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 ( + 'validation_verdict', ?1, ?2, NULL, ?3, NULL, + 'proposal-apply-intent', ?4, NULL, ?5, ?6, ?7, NULL, ?8, ?8 + )", 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()), + proposal_id, + familiar_id, + state.weave_hash, + approver.as_str(), + detail, files_touched, - format!("{:?}", coven_threads_core::Channel::Mutation).to_lowercase(), + format!("{channel:?}").to_lowercase(), now, ], ) - .context("appending proposal decision to ward_audit")?; + .context("appending proposal apply intent")?; 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}; +fn load_proposal_apply_intent( + conn: &rusqlite::Connection, + proposal_id: &str, +) -> Result> { + use rusqlite::OptionalExtension; + + 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 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() }) +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); } - }; - 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 + 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(()) } -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!({})), - } +struct ProposalTerminalAudit { + event_type: String, + files_touched: Vec, } -fn split_path_query(path: &str) -> (&str, Option<&str>) { - match path.split_once('?') { - Some((route, query)) => (route, Some(query)), - None => (path, None), +fn terminal_decision_label(event_type: &str) -> &'static str { + match event_type { + "proposal_approved" => "approved", + "proposal_vetoed" => "vetoed", + _ => "rejected", } } -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 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 session_action_id<'a>(path: &'a str, suffix: &str) -> &'a str { - path.trim_start_matches("/sessions/") - .strip_suffix(suffix) - .unwrap_or_default() +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 } -pub(crate) fn current_timestamp() -> String { - Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true) +#[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) } -/// 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); +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(()) +} - let now_secs = std::time::SystemTime::now() +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); @@ -6332,805 +8074,2039 @@ description = "digs deep" } #[test] - fn get_cast_codes_returns_grammar_templates() -> Result<()> { + 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(()) + } + + #[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}"#), + )?; + + 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 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 response = handle_request_with_body( + "DELETE", + "/api/v1/skills/eval-loop/sage/run-lock", + home, + None, + Some(r#"{"force":false}"#), + )?; + + 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(()) + } + + 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." + +[[familiar]] +id = "sage" +display_name = "Sage" +role = "Research" +description = "Reads and synthesizes." +icon = "ph:leaf-fill" +"#, + )?; + Ok(()) + } + + #[test] + 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"] + +[[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 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 familiar_ward_route_fails_closed_on_unknown_and_unconfigured() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + seed_familiars_toml(home)?; + + // 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"); + + // 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 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( + 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(); + + // 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}"), + home, + None, + )?; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["proposal"]["reviewKind"], "coherence"); + + let response = handle_request( + "GET", + "/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); + let body: serde_json::Value = serde_json::from_str(&response.body)?; + assert_eq!(body["error"]["code"], "invalid_request"); + 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["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!( + 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()?; + 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":"🌿"}"#), + )?; + 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 put_familiar_icon_inserts_when_absent() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + 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, 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 put_familiar_icon_clears_when_null() -> 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, + 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 put_familiar_icon_returns_404_for_unknown_id() -> 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", + home, + None, + Some(r#"{"icon":"ph:ghost-fill"}"#), + )?; + 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 put_familiar_icon_rejects_non_string_icon() -> 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, + 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 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(()) + } + + // ---- 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"] + +[[surface]] +path = "SOUL.md" +tier = 0 + +[[surface]] +path = "reviewed/" +tier = 1 +"#, + )?; + Ok(workspace) + } + + fn post_edits(home: &Path, body: &str) -> Result { + handle_request_with_body( + "POST", + "/api/v1/familiars/sage/edits", + home, + None, + Some(body), + ) + } + + #[test] + fn post_familiar_edits_applies_and_audits_tier2_write() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + 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!( + std::fs::read_to_string(workspace.join("notes/today.md"))?, + "hello ward" + ); + Ok(()) + } + + #[test] + fn post_familiar_edits_refuses_traversal_and_writes_nothing() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let workspace = seed_warded_familiar(home)?; + + // 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["error"]["code"], "ward_refused"); + assert!(!workspace.join("notes/ok.md").exists()); + assert!(!home.join("familiars/escape.md").exists()); + Ok(()) + } + + #[test] + fn post_familiar_edits_refuses_unsigned_protected_write() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + let workspace = seed_warded_familiar(home)?; + + let response = post_edits( + home, + r#"{"edits":[{"target":"SOUL.md","contents":"new identity"}]}"#, + )?; + + 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_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 post_familiar_edits_holds_authorized_protected_write() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + 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 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 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)?; + 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 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 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( + 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(()) + } + + #[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( + 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["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(()) + } + + #[test] + fn threads_weaves_returns_cave_normalizable_weave_entries() -> Result<()> { + let temp = tempfile::tempdir()?; + let home = temp.path(); + seed_warded_familiar(home)?; + + 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 threads_weaves_skips_malformed_ward_without_aborting_fleet_read() -> 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"] + +[[surface]] +path = "SOUL.md" +tier = 0 +"#, + )?; + + 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!( + 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}" + ); + Ok(()) + } + + #[test] + fn threads_weaves_omits_familiars_without_ward_toml() -> 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 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)?; + + assert_eq!(response.status, 200, "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"); + 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(()) } + 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":"SOUL.md","contents":"new identity"}], + "principalKeyFingerprint":"fpr-val"}"#, + )?; + 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!(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)) + } + + 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, + approval_path, + staged_at, + coven_threads_core::Channel::Mutation, + ) + } + + 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, + ) + } + + 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(target); + let pending = coven_threads_core::PendingProposal { + id: proposal_id, + familiar_id, + writer: coven_threads_core::WriterId::new("principal:fpr-val"), + channel, + thread_id: coven_threads_core::ThreadId::new(), + fray: coven_threads_core::FrayOrSnap::Frayed { + strand: None, + 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"after"), + }], + staged_at, + }; + let diff = + coven_threads_core::MaterializedDiff::try_new(vec![coven_threads_core::SurfaceDiff { + surface: surface.clone(), + before: Some(b"before".to_vec()), + after: Some(b"after".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, + affected_surfaces: vec![surface], + affected_regions: evidence.iter().map(|item| item.region_id.clone()).collect(), + path_tier_floor, + approval_path, + 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)?; + 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())) + } + + 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 delete_eval_loop_run_lock_clears_with_force() -> Result<()> { + fn threads_scheduled_human_required_enforces_rationale_and_applies() -> 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 (pending, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApprovalWithRationale, + time::OffsetDateTime::now_utc(), + )?; + let missing_rationale_body = scheduled_decision_body(home, &proposal_id, None)?; - let response = handle_request_with_body( - "DELETE", - "/api/v1/skills/eval-loop/sage/run-lock", + let missing_rationale = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"force":true}"#), + Some(&missing_rationale_body), )?; + 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); - 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()); + 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(&approved_body), + )?; + assert_eq!(approved.status, 200, "got {}", approved.body); + assert_eq!( + std::fs::read_to_string(home.join("familiars/sage/reviewed/skill.md"))?, + "after" + ); + 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 delete_eval_loop_run_lock_rejects_fresh_lock_without_force() -> Result<()> { + fn threads_scheduled_manual_decision_requires_matching_revision() -> 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 (pending, proposal_id) = stage_scheduled_reviewed_edit( + home, + coven_threads_core::ApprovalPath::HumanApproval, + time::OffsetDateTime::now_utc(), + )?; - let response = handle_request_with_body( - "DELETE", - "/api/v1/skills/eval-loop/sage/run-lock", + let missing = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, - Some(r#"{"force":false}"#), + Some("{}"), )?; + assert_eq!(missing.status, 409); + assert!(missing.body.contains("proposal-revision-required")); + assert!(pending.exists()); - 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(()) - } - - 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." + 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()); -[[familiar]] -id = "sage" -display_name = "Sage" -role = "Research" -description = "Reads and synthesizes." -icon = "ph:leaf-fill" -"#, + 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 familiar_ward_route_reports_declared_surface() -> Result<()> { + fn threads_scheduled_audits_preserve_committed_channel() -> 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"] - -[[surface]] -path = "SOUL.md" -tier = 0 - -[[surface]] -path = "memory/" -tier = 2 -"#, + 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 decision_body = scheduled_decision_body(home, &proposal_id, None)?; - let response = handle_request("GET", "/api/v1/familiars/sage/ward", 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["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"); + let approved = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some(&decision_body), + )?; + 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 familiar_ward_route_fails_closed_on_unknown_and_unconfigured() -> Result<()> { + fn scheduled_apply_intent_uses_committed_before_image() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_familiars_toml(home)?; - - // 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 (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")?; - // 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"); + let before_images = proposal_before_images( + &workspace, + &["reviewed/skill.md".to_string()], + Some(&scheduled), + )?; - // 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"); - } + assert_eq!( + before_images[0] + .contents + .to_bytes() + .map_err(anyhow::Error::msg)?, + b"before" + ); Ok(()) } #[test] - fn threads_proposals_lists_staged_coherence_proposal() -> Result<()> { + fn threads_scheduled_veto_window_delays_apply_and_records_veto() -> 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( + 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, - r#"{"edits":[{"target":"reviewed/skill.md","contents":"tweak"}]}"#, + coven_threads_core::ApprovalPath::FamiliarCoherence { veto }, + time::OffsetDateTime::now_utc(), )?; - let staged_body: serde_json::Value = serde_json::from_str(&staged.body)?; - let proposal_id = staged_body["proposalId"].as_str().expect("id").to_string(); + let premature_body = scheduled_decision_body(home, &proposal_id, None)?; - // 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}"), + let premature = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), home, None, + Some(&premature_body), )?; - assert_eq!(response.status, 200); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["proposal"]["reviewKind"], "coherence"); + 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 response = handle_request( - "GET", - "/api/v1/threads/proposals/00000000-0000-0000-0000-000000000000", + 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(&veto_body), )?; - 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); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "invalid_request"); + 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 put_familiar_icon_updates_existing_value() -> Result<()> { + fn threads_scheduled_applies_only_after_veto_deadline() -> 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, + 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(r#"{"icon":"🌿"}"#), + Some(&decision_body), )?; - 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!(approved.status, 200, "got {}", approved.body); + 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 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_inserts_when_absent() -> 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 decision_body = scheduled_decision_body(home, &proposal_id, None)?; + 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(&decision_body), )?; - 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-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_clears_when_null() -> 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 decision_body = scheduled_decision_body(home, &proposal_id, None)?; + 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":null}"#), + Some(&decision_body), )?; - 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!(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_returns_404_for_unknown_id() -> 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/ghost/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 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#"{"icon":"ph:ghost-fill"}"#), + Some(&decision_body), + ); + 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, 404); - let body: serde_json::Value = serde_json::from_str(&response.body)?; - assert_eq!(body["error"]["code"], "familiar_not_found"); + + 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_rejects_non_string_icon() -> 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/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(), )?; - 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")); + + 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_with_empty_body_clears() -> 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", 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 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 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(()) } - // ---- 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"] + #[test] + fn threads_scheduler_recovers_durable_approval_claim() -> 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::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(&decision_body), + ); + 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 decision_body = + scheduled_decision_body(home, &proposal_id, Some("claim-time rationale"))?; + 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(&decision_body), + ); + 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 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(&decision_body), ); + 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, + )?; + let decision_body = scheduled_decision_body(home, &proposal_id, None)?; - // 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(&decision_body), )?; + 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)?; - - // First signed request bootstraps the content baseline (held). - let first = post_edits( + 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, - 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")?; + None, + Some("{}"), + ); + assert!(interrupted.is_err()); + force_recovery_ward_refusal(proposal_id.clone()); - let response = 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 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"); - let pending = body["threadsGate"]["outcome"]["pendingPath"] - .as_str() - .expect("staged outcome carries pendingPath"); + 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!( - std::path::Path::new(pending).exists(), - "pending proposal file must exist" + pending.exists(), + "refused recovery must restore pending JSON" ); - // 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" + 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" ); - // 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!( + find_pending_decision_claim(home, &proposal_id, "approve").is_none(), + "refused recovery must consume the claimed filename" + ); + + let retry = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/approve"), + home, + None, + Some("{}"), )?; - assert_eq!(decision, "degrade_to_proposal"); + assert_eq!(retry.status, 200, "got {}", retry.body); 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_config_diverged() -> 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( + 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, - 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")?; + 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 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"}, - {"target":"../escape.md","contents":"nope"} - ], "principalKeyFingerprint":"fpr-val"}"#, + None, + Some("{}"), )?; - 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()); + + 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!( + !pending.exists(), + "claim must remain the sole proposal file" + ); + assert_eq!( + std::fs::read_to_string(workspace.join("SOUL.md"))?, + "approved identity" + ); + let conn = store::open_store(&home.join("coven.sqlite3"))?; + 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); Ok(()) } #[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. + fn threads_approve_recovery_preserves_claim_if_ward_disappears() -> Result<()> { let temp = tempfile::tempdir()?; let home = temp.path(); - seed_warded_familiar(home)?; - 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":"notes/today.md","contents":"hello ward"}]}"#, + 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"))?; + + let retry = 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!( - 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: String = conn.query_row( - "SELECT event_type FROM ward_audit WHERE proposal_id = ?1 ORDER BY id DESC LIMIT 1", - [&proposal_id], - |row| row.get(0), + let opposite = handle_request_with_body( + "POST", + &format!("/api/v1/threads/proposals/{proposal_id}/reject"), + home, + None, + Some("{}"), )?; - assert_eq!(event_type, "proposal_approved"); + 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(()) } @@ -7274,6 +10250,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( @@ -7325,6 +10313,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..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) @@ -1865,6 +1889,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 +2120,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 +2631,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)); @@ -2741,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()?; 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..f3521932 --- /dev/null +++ b/crates/coven-cli/src/proposal_scheduler.rs @@ -0,0 +1,716 @@ +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 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 { + 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")); + } +} 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..045a9007 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( @@ -247,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. @@ -373,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('\\') { @@ -464,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], @@ -504,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..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 @@ -66,9 +73,15 @@ //! 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; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; @@ -724,6 +737,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 +765,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 +866,566 @@ 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, + paths: 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 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, + 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, + paths: 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(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(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_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, + error.context(format!( + "committing approved write to {}", + write.path.display() + )), + ); + } + swapped.push(index); + + let verification = (|| -> Result<()> { + 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 { + 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(paths) = &write.paths { + std::fs::remove_file(&paths.displaced).with_context(|| { + format!( + "removing approved-write backup {}", + paths.displaced.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 paths = write + .paths + .as_ref() + .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)); + } + } + 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, + 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!( + "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(()) +} + +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(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", windows)) +} + +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); + } + match ApprovedWritePaths::new(path, staged.clone()) { + Ok(paths) => Ok(paths), + Err(error) => { + let _ = std::fs::remove_file(staged); + Err(error) + } + } +} + +#[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_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( + from: *const libc::c_char, + to: *const libc::c_char, + flags: u32, + ) -> libc::c_int; + } + + 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(replacement.as_ptr(), target.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_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 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, + replacement.as_ptr(), + libc::AT_FDCWD, + target.as_ptr(), + RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).context("atomically exchanging approved target") + } +} + +#[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 @@ -1070,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(), @@ -1108,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]]` @@ -1485,6 +2137,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();