use crate::domain::{ unix_secs_now, AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel, EvidenceHealth, PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION, }; use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; use crate::state_machine::{ self, transition_commitment, transition_runtime, CommitmentAction, RuntimeAction, }; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::Path; use std::time::Duration; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct RuntimeTransitionPayload { schema_version: u16, action: String, from: RuntimeState, to: RuntimeState, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct CommitmentTransitionPayload { schema_version: u16, commitment_id: String, action: String, from: CommitmentState, to: CommitmentState, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct CommitmentCreatedPayload { schema_version: u16, commitment_id: String, source: CommitmentSource, next_action: String, success_condition: String, timebox_secs: u64, state: CommitmentState, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct PolicyAppliedPayload { schema_version: u16, policy_id: String, commitment_id: String, runtime_state: RuntimeState, enforcement_level: EnforcementLevel, allowed_context: AllowedContext, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct TransitionStartedPayload { schema_version: u16, commitment_id: String, transition_policy_id: String, reason: String, return_target: String, expected_duration_secs: u64, runtime_from: RuntimeState, runtime_to: RuntimeState, commitment_from: CommitmentState, commitment_to: CommitmentState, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct ViolationObservedPayload { schema_version: u16, reason: String, evidence: Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct EvidenceObservedPayload { schema_version: u16, health: EvidenceHealth, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct ReviewCompletedPayload { schema_version: u16, commitment_id: String, outcome: CommitmentState, rating: u8, rating_f64: f64, reviewed_window_count: u32, } #[derive(Clone, Debug, PartialEq, Eq)] struct PendingTransitionPolicy { commitment_id: String, policy_id: String, runtime_state: RuntimeState, enforcement_level: EnforcementLevel, allowed_context: AllowedContext, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SessionTimingSummary { pub active_elapsed_secs: u64, pub currently_in_transition: bool, } #[derive(Debug)] pub struct SessionController { runtime_state: RuntimeState, active_commitment: Option, active_policy: Option, review_completed: bool, event_log: EventLog, } impl SessionController { pub fn new(event_log_path: impl AsRef) -> Result { let event_log = EventLog::open(event_log_path.as_ref()).with_context(|| { format!( "open session event log at {}", event_log_path.as_ref().display() ) })?; let (runtime_state, active_commitment, active_policy, review_completed) = hydrate_session(&event_log).context("hydrate session from event log")?; Ok(Self { runtime_state, active_commitment, active_policy, review_completed, event_log, }) } pub fn runtime_state(&self) -> RuntimeState { self.runtime_state } pub fn active_policy(&self) -> Option<&PolicySnapshot> { self.active_policy.as_ref() } pub fn active_commitment(&self) -> Option<&Commitment> { self.active_commitment.as_ref() } pub fn timing_summary(&self) -> Result> { let Some(commitment) = self.active_commitment.as_ref() else { return Ok(None); }; self.timing_summary_at(commitment, unix_secs_now()) } fn timing_summary_at( &self, commitment: &Commitment, now_unix_secs: u64, ) -> Result> { let commitment_id = commitment.id.as_str(); let mut active_elapsed_secs = 0_u64; let mut active_started_at: Option = None; let mut open_transition_started_at: Option = None; let mut saw_commitment = false; for record in self.event_log.records()? { if record.commitment_id.as_deref() != Some(commitment_id) { continue; } match record.event_type { EventType::CommitmentCreated => { let payload: CommitmentCreatedPayload = parse_payload(&record, "commitment created")?; if payload.commitment_id == commitment_id { active_started_at = Some(record.timestamp_unix_secs); open_transition_started_at = None; saw_commitment = true; } } EventType::TransitionStarted => { let payload: TransitionStartedPayload = parse_payload(&record, "transition started")?; if payload.commitment_id == commitment_id && open_transition_started_at.is_none() { if let Some(started_at) = active_started_at.take() { active_elapsed_secs = active_elapsed_secs.saturating_add( record.timestamp_unix_secs.saturating_sub(started_at), ); } open_transition_started_at = Some(record.timestamp_unix_secs); } } EventType::RuntimeTransition => { let payload: RuntimeTransitionPayload = parse_payload(&record, "runtime transition")?; if payload.action == "complete_for_review" { if let Some(started_at) = active_started_at.take() { active_elapsed_secs = active_elapsed_secs.saturating_add( record.timestamp_unix_secs.saturating_sub(started_at), ); } } if payload.action == "return_to_active" && open_transition_started_at.take().is_some() { active_started_at = Some(record.timestamp_unix_secs); } } EventType::CommitmentTransition => { let payload: CommitmentTransitionPayload = parse_payload(&record, "commitment transition")?; if payload.commitment_id == commitment_id && payload.action == "return_from_pause" && open_transition_started_at.take().is_some() { active_started_at = Some(record.timestamp_unix_secs); } } _ => {} } } if !saw_commitment { return Ok(None); } if let Some(started_at) = active_started_at { active_elapsed_secs = active_elapsed_secs.saturating_add(now_unix_secs.saturating_sub(started_at)); } Ok(Some(SessionTimingSummary { active_elapsed_secs, currently_in_transition: self.runtime_state == RuntimeState::Transition && open_transition_started_at.is_some(), })) } pub fn enter_planning(&mut self) -> Result<()> { let previous_runtime_state = self.runtime_state; let next_runtime_state = transition_runtime(previous_runtime_state, RuntimeAction::EnterPlanning) .context("enter planning runtime transition")?; self.event_log .append( EventType::RuntimeTransition, next_runtime_state, self.active_commitment .as_ref() .map(|commitment| commitment.id.clone()), serde_json::to_value(RuntimeTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, action: "enter_planning".to_string(), from: previous_runtime_state, to: next_runtime_state, }) .context("serialize runtime transition payload")?, ) .context("log runtime transition into planning")?; self.runtime_state = next_runtime_state; Ok(()) } pub fn complete_for_review(&mut self) -> Result<()> { let previous_runtime_state = self.runtime_state; let next_runtime_state = transition_runtime(previous_runtime_state, RuntimeAction::CompleteForReview) .context("complete active session for review runtime transition")?; let commitment_id = self .active_commitment .as_ref() .ok_or_else(|| anyhow!("active commitment is required to complete for review"))? .id .clone(); self.event_log .append( EventType::RuntimeTransition, next_runtime_state, Some(commitment_id), serde_json::to_value(RuntimeTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, action: "complete_for_review".to_string(), from: previous_runtime_state, to: next_runtime_state, }) .context("serialize runtime transition payload")?, ) .context("log runtime transition into review")?; self.runtime_state = next_runtime_state; self.active_policy = None; self.review_completed = false; Ok(()) } pub fn complete_review_and_return_to_planning( &mut self, rating: u8, rating_f64: f64, reviewed_window_count: u32, ) -> Result<()> { let previous_runtime_state = self.runtime_state; let next_runtime_state = transition_runtime(previous_runtime_state, RuntimeAction::ContinuePlanning) .context("continue planning after review runtime transition")?; let active_commitment = self .active_commitment .as_ref() .ok_or_else(|| anyhow!("active reviewed commitment is required to complete review"))?; let commitment_id = active_commitment.id.clone(); let previous_commitment_state = active_commitment.state.clone(); let next_commitment_state = if self.review_completed && previous_commitment_state == CommitmentState::Completed { CommitmentState::Completed } else { transition_commitment( previous_commitment_state.clone(), CommitmentAction::Complete, ) .context("complete reviewed commitment")? }; let completion_payload = ReviewCompletedPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: commitment_id.clone(), outcome: CommitmentState::Completed, rating, rating_f64, reviewed_window_count, }; validate_review_completed_payload(&completion_payload) .context("validate review completed payload")?; let transition_payload = RuntimeTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, action: "return_to_planning_after_review".to_string(), from: previous_runtime_state, to: next_runtime_state, }; if self.review_completed { if previous_commitment_state == CommitmentState::Completed { self.event_log .append( EventType::RuntimeTransition, next_runtime_state, Some(commitment_id.clone()), serde_json::to_value(transition_payload) .context("serialize runtime transition payload")?, ) .context("log runtime transition from review to planning")?; } else { self.event_log .append_batch([ PendingEventRecord { event_type: EventType::CommitmentTransition, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.clone()), payload_json: serde_json::to_value(CommitmentTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: commitment_id.clone(), action: "complete".to_string(), from: previous_commitment_state.clone(), to: next_commitment_state.clone(), }) .context("serialize commitment transition payload")?, }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: next_runtime_state, commitment_id: Some(commitment_id.clone()), payload_json: serde_json::to_value(transition_payload) .context("serialize runtime transition payload")?, }, ]) .context("log commitment completion and runtime transition to planning")?; } } else { self.event_log .append_batch([ PendingEventRecord { event_type: EventType::ReviewCompleted, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.clone()), payload_json: serde_json::to_value(completion_payload) .context("serialize review completed payload")?, }, PendingEventRecord { event_type: EventType::CommitmentTransition, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.clone()), payload_json: serde_json::to_value(CommitmentTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: commitment_id.clone(), action: "complete".to_string(), from: previous_commitment_state.clone(), to: next_commitment_state.clone(), }) .context("serialize commitment transition payload")?, }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: next_runtime_state, commitment_id: Some(commitment_id.clone()), payload_json: serde_json::to_value(transition_payload) .context("serialize runtime transition payload")?, }, ]) .context("log review completion, commitment completion, and runtime transition")?; } self.runtime_state = next_runtime_state; self.active_commitment = None; self.active_policy = None; self.review_completed = false; Ok(()) } pub fn start_manual_commitment( &mut self, next_action: impl Into, success_condition: impl Into, timebox: Duration, ) -> Result<()> { let previous_runtime_state = self.runtime_state; let next_runtime_state = transition_runtime( previous_runtime_state, RuntimeAction::Activate { policy_accepted: true, }, ) .context("activate runtime for manual commitment")?; let mut commitment = Commitment::new_manual(next_action, success_condition, timebox) .context("create manual commitment")?; commitment.state = transition_commitment(commitment.state.clone(), CommitmentAction::Activate) .context("activate manual commitment")?; let policy = PolicySnapshot::try_for_runtime( commitment.id.clone(), next_runtime_state, EnforcementLevel::Warn, AllowedContext::default(), ) .context("create active policy snapshot")?; self.event_log .append_batch([ PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: next_runtime_state, commitment_id: Some(commitment.id.clone()), payload_json: serde_json::to_value(CommitmentCreatedPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: commitment.id.clone(), source: commitment.source.clone(), next_action: commitment.next_action.clone(), success_condition: commitment.success_condition.clone(), timebox_secs: commitment.timebox_secs, state: commitment.state.clone(), }) .context("serialize commitment payload")?, }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: next_runtime_state, commitment_id: Some(commitment.id.clone()), payload_json: serde_json::to_value(PolicyAppliedPayload { schema_version: POLICY_SCHEMA_VERSION, policy_id: policy.id.clone(), commitment_id: policy.commitment_id.clone(), runtime_state: policy.runtime_state, enforcement_level: policy.enforcement_level, allowed_context: policy.allowed_context.clone(), }) .context("serialize policy payload")?, }, ]) .context("log commitment activation")?; self.runtime_state = next_runtime_state; self.active_commitment = Some(commitment); self.active_policy = Some(policy); Ok(()) } pub fn start_transition( &mut self, reason: impl Into, return_target: impl Into, expected_duration: Duration, ) -> Result<()> { let active_commitment = self .active_commitment .as_ref() .ok_or_else(|| anyhow!("active commitment is required to start transition"))?; let active_policy = self .active_policy .as_ref() .ok_or_else(|| anyhow!("active policy is required to start transition"))?; let composite = state_machine::start_transition(self.runtime_state, active_commitment.state.clone()) .context("start transition state change")?; let previous_commitment_state = active_commitment.state.clone(); let next_commitment_state = composite.commitment_state.clone(); let mut next_commitment = active_commitment.clone(); next_commitment.state = next_commitment_state.clone(); let transition_policy = PolicySnapshot::try_for_runtime( next_commitment.id.clone(), composite.runtime_state, active_policy.enforcement_level, active_policy.allowed_context.clone(), ) .context("create transition policy snapshot")?; next_commitment.transition_policy_id = Some(transition_policy.id.clone()); let transition_payload = TransitionStartedPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: next_commitment.id.clone(), transition_policy_id: transition_policy.id.clone(), reason: reason.into().trim().to_string(), return_target: return_target.into().trim().to_string(), expected_duration_secs: expected_duration.as_secs(), runtime_from: self.runtime_state, runtime_to: composite.runtime_state, commitment_from: previous_commitment_state, commitment_to: next_commitment_state, }; validate_transition_started_payload(&transition_payload)?; self.event_log .append_batch([ PendingEventRecord { event_type: EventType::TransitionStarted, runtime_state: composite.runtime_state, commitment_id: Some(next_commitment.id.clone()), payload_json: serde_json::to_value(transition_payload) .context("serialize transition started payload")?, }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: composite.runtime_state, commitment_id: Some(next_commitment.id.clone()), payload_json: serde_json::to_value(PolicyAppliedPayload { schema_version: POLICY_SCHEMA_VERSION, policy_id: transition_policy.id.clone(), commitment_id: transition_policy.commitment_id.clone(), runtime_state: transition_policy.runtime_state, enforcement_level: transition_policy.enforcement_level, allowed_context: transition_policy.allowed_context.clone(), }) .context("serialize transition policy payload")?, }, ]) .context("log transition start")?; self.runtime_state = composite.runtime_state; self.active_commitment = Some(next_commitment); self.active_policy = Some(transition_policy); Ok(()) } pub fn return_from_transition(&mut self) -> Result<()> { let active_commitment = self .active_commitment .as_ref() .ok_or_else(|| anyhow!("active commitment is required to return from transition"))?; let active_policy = self .active_policy .as_ref() .ok_or_else(|| anyhow!("active policy is required to return from transition"))?; let previous_runtime_state = self.runtime_state; let next_runtime_state = transition_runtime(previous_runtime_state, RuntimeAction::ReturnToActive) .context("return from transition runtime transition")?; let previous_commitment_state = active_commitment.state.clone(); let next_commitment_state = transition_commitment( previous_commitment_state.clone(), CommitmentAction::ReturnFromPause, ) .context("return paused commitment to active")?; let mut next_commitment = active_commitment.clone(); next_commitment.state = next_commitment_state.clone(); next_commitment.transition_policy_id = None; let active_policy = PolicySnapshot::try_for_runtime( next_commitment.id.clone(), next_runtime_state, active_policy.enforcement_level, active_policy.allowed_context.clone(), ) .context("create returned active policy snapshot")?; self.event_log .append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: next_runtime_state, commitment_id: Some(next_commitment.id.clone()), payload_json: serde_json::to_value(RuntimeTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, action: "return_to_active".to_string(), from: previous_runtime_state, to: next_runtime_state, }) .context("serialize return runtime transition payload")?, }, PendingEventRecord { event_type: EventType::CommitmentTransition, runtime_state: next_runtime_state, commitment_id: Some(next_commitment.id.clone()), payload_json: serde_json::to_value(CommitmentTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: next_commitment.id.clone(), action: "return_from_pause".to_string(), from: previous_commitment_state, to: next_commitment_state, }) .context("serialize return commitment transition payload")?, }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: next_runtime_state, commitment_id: Some(next_commitment.id.clone()), payload_json: serde_json::to_value(PolicyAppliedPayload { schema_version: POLICY_SCHEMA_VERSION, policy_id: active_policy.id.clone(), commitment_id: active_policy.commitment_id.clone(), runtime_state: active_policy.runtime_state, enforcement_level: active_policy.enforcement_level, allowed_context: active_policy.allowed_context.clone(), }) .context("serialize returned active policy payload")?, }, ]) .context("log return from transition")?; self.runtime_state = next_runtime_state; self.active_commitment = Some(next_commitment); self.active_policy = Some(active_policy); Ok(()) } pub fn record_violation(&mut self, reason: impl Into, evidence: Value) -> Result<()> { let payload = ViolationObservedPayload { schema_version: POLICY_SCHEMA_VERSION, reason: reason.into().trim().to_string(), evidence, }; validate_violation_observed_payload(&payload)?; if self.runtime_state != RuntimeState::Active { return Err(anyhow!("violation requires active runtime")); } let commitment_id = self .active_commitment .as_ref() .ok_or_else(|| anyhow!("violation requires active commitment"))? .id .clone(); self.event_log .append( EventType::ViolationObserved, RuntimeState::Active, Some(commitment_id), serde_json::to_value(payload).context("serialize violation observed payload")?, ) .context("log violation observation")?; Ok(()) } pub fn record_evidence(&mut self, health: EvidenceHealth) -> Result<()> { let payload = EvidenceObservedPayload { schema_version: POLICY_SCHEMA_VERSION, health, }; validate_evidence_observed_payload(&payload)?; let runtime_state = self.runtime_state; let commitment_id = self .active_commitment .as_ref() .map(|commitment| commitment.id.clone()); self.event_log .append( EventType::EvidenceObserved, runtime_state, commitment_id, serde_json::to_value(payload).context("serialize evidence observed payload")?, ) .context("log evidence observation")?; Ok(()) } } fn hydrate_session( event_log: &EventLog, ) -> Result<( RuntimeState, Option, Option, bool, )> { let mut runtime_state = RuntimeState::Locked; let mut active_commitment: Option = None; let mut active_policy: Option = None; let mut pending_transition_policy: Option = None; let mut review_completed = false; for record in event_log.records()? { if pending_transition_policy.is_some() && record.event_type != EventType::PolicyApplied { return Err(anyhow!( "pending transition policy expected PolicyApplied at sequence {}", record.sequence )); } match record.event_type { EventType::RuntimeTransition => { let payload: RuntimeTransitionPayload = parse_payload(&record, "runtime transition")?; ensure_schema_version(payload.schema_version, record.sequence)?; if payload.from != runtime_state { return Err(anyhow!( "runtime transition source mismatch at sequence {}: expected {:?}, found {:?}", record.sequence, runtime_state, payload.from )); } let action = match payload.action.as_str() { "enter_planning" => RuntimeAction::EnterPlanning, "complete_for_review" => RuntimeAction::CompleteForReview, "return_to_active" => RuntimeAction::ReturnToActive, "return_to_planning_after_review" => RuntimeAction::ContinuePlanning, _ => { return Err(anyhow!( "unsupported runtime transition action '{}' at sequence {}", payload.action, record.sequence )); } }; match action { RuntimeAction::CompleteForReview => { let commitment = active_commitment.as_ref().ok_or_else(|| { anyhow!( "active commitment is required to enter review at sequence {}", record.sequence ) })?; ensure_record_commitment(&record, &commitment.id)?; } RuntimeAction::ContinuePlanning => { let commitment = active_commitment.as_ref().ok_or_else(|| { anyhow!( "active reviewed commitment is required before returning to planning at sequence {}", record.sequence ) })?; let legacy_unlinked_review_return = record.commitment_id.is_none() && !review_completed && commitment.state == CommitmentState::Active; if !legacy_unlinked_review_return { ensure_record_commitment(&record, &commitment.id)?; if !review_completed { return Err(anyhow!( "review completion is required before returning to planning at sequence {}", record.sequence )); } if commitment.state != CommitmentState::Completed { return Err(anyhow!( "reviewed commitment must be terminal before returning to planning at sequence {}", record.sequence )); } } } _ => {} } let next = transition_runtime(runtime_state, action).with_context(|| { format!("replay runtime transition at sequence {}", record.sequence) })?; if payload.to != next || record.runtime_state != next { return Err(anyhow!( "runtime transition target mismatch at sequence {}", record.sequence )); } runtime_state = next; match action { RuntimeAction::CompleteForReview => { active_policy = None; review_completed = false; } RuntimeAction::ContinuePlanning => { active_commitment = None; active_policy = None; review_completed = false; } _ => {} } } EventType::CommitmentTransition => { let payload: CommitmentTransitionPayload = parse_payload(&record, "commitment transition")?; ensure_schema_version(payload.schema_version, record.sequence)?; ensure_record_commitment(&record, &payload.commitment_id)?; if record.runtime_state != runtime_state { return Err(anyhow!( "commitment transition runtime mismatch at sequence {}", record.sequence )); } let commitment = active_commitment.as_mut().ok_or_else(|| { anyhow!( "commitment transition references unknown commitment '{}' at sequence {}", payload.commitment_id, record.sequence ) })?; if commitment.id != payload.commitment_id { return Err(anyhow!( "commitment transition commitment mismatch at sequence {}", record.sequence )); } if commitment.state != payload.from { return Err(anyhow!( "commitment transition source mismatch at sequence {}", record.sequence )); } let action = match payload.action.as_str() { "return_from_pause" => CommitmentAction::ReturnFromPause, "complete" => { if runtime_state != RuntimeState::Review { return Err(anyhow!( "commitment completion requires review runtime at sequence {}", record.sequence )); } if !review_completed { return Err(anyhow!( "review completion is required before commitment completion at sequence {}", record.sequence )); } CommitmentAction::Complete } _ => { return Err(anyhow!( "unsupported commitment transition action '{}' at sequence {}", payload.action, record.sequence )); } }; let next = transition_commitment(commitment.state.clone(), action).with_context(|| { format!( "replay commitment transition at sequence {}", record.sequence ) })?; if payload.to != next { return Err(anyhow!( "commitment transition target mismatch at sequence {}", record.sequence )); } commitment.state = next; if commitment.state == CommitmentState::Active { commitment.transition_policy_id = None; } } EventType::CommitmentCreated => { let payload: CommitmentCreatedPayload = parse_payload(&record, "commitment created")?; ensure_schema_version(payload.schema_version, record.sequence)?; ensure_record_commitment(&record, &payload.commitment_id)?; if active_commitment.is_some() || active_policy.is_some() { return Err(anyhow!( "commitment created while active commitment already exists at sequence {}", record.sequence )); } if runtime_state != RuntimeState::Planning { return Err(anyhow!( "commitment activation requires planning runtime at sequence {}", record.sequence )); } if record.runtime_state != RuntimeState::Active { return Err(anyhow!( "commitment activation runtime mismatch at sequence {}", record.sequence )); } if payload.state != CommitmentState::Active { return Err(anyhow!( "commitment activation state mismatch at sequence {}", record.sequence )); } let commitment = Commitment { id: payload.commitment_id, created_at_unix_secs: record.timestamp_unix_secs, source: payload.source, project_id: None, template_id: None, next_action: payload.next_action, success_condition: payload.success_condition, timebox_secs: payload.timebox_secs, transition_policy_id: None, state: payload.state, }; commitment.validate().with_context(|| { format!("validate commitment at sequence {}", record.sequence) })?; active_commitment = Some(commitment); } EventType::PolicyApplied => { let payload: PolicyAppliedPayload = parse_payload(&record, "policy applied")?; ensure_schema_version(payload.schema_version, record.sequence)?; if let Some(expected) = pending_transition_policy.take() { apply_pending_transition_policy( &record, payload, expected, &mut runtime_state, &mut active_commitment, &mut active_policy, )?; continue; } ensure_record_commitment(&record, &payload.commitment_id)?; let commitment = active_commitment.as_ref().ok_or_else(|| { anyhow!( "policy references unknown commitment '{}' at sequence {}", payload.commitment_id, record.sequence ) })?; if commitment.id != payload.commitment_id { return Err(anyhow!( "policy commitment mismatch at sequence {}", record.sequence )); } let next = match (runtime_state, &commitment.state, payload.runtime_state) { (RuntimeState::Planning, CommitmentState::Active, RuntimeState::Active) => { transition_runtime( runtime_state, RuntimeAction::Activate { policy_accepted: true, }, ) .with_context(|| { format!("replay policy application at sequence {}", record.sequence) })? } (RuntimeState::Active, CommitmentState::Active, RuntimeState::Active) => { RuntimeState::Active } _ => { return Err(anyhow!( "policy requires active commitment at sequence {}", record.sequence )); } }; if payload.runtime_state != next || record.runtime_state != next { return Err(anyhow!( "policy runtime target mismatch at sequence {}", record.sequence )); } let policy = PolicySnapshot { id: payload.policy_id, commitment_id: payload.commitment_id, schema_version: POLICY_SCHEMA_VERSION, created_at_unix_secs: record.timestamp_unix_secs, runtime_state: payload.runtime_state, enforcement_level: payload.enforcement_level, allowed_context: payload.allowed_context, required_monitors: Vec::new(), violation_actions: Vec::new(), expires_at_unix_secs: None, generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), }; policy .validate() .with_context(|| format!("validate policy at sequence {}", record.sequence))?; runtime_state = next; if runtime_state == RuntimeState::Transition { if let Some(commitment) = active_commitment.as_mut() { commitment.transition_policy_id = Some(policy.id.clone()); } } active_policy = Some(policy); } EventType::TransitionStarted => { let payload: TransitionStartedPayload = parse_payload(&record, "transition started")?; ensure_schema_version(payload.schema_version, record.sequence)?; validate_transition_started_payload(&payload).with_context(|| { format!( "validate transition started payload at sequence {}", record.sequence ) })?; let active = active_commitment.as_ref().ok_or_else(|| { anyhow!( "transition requires active commitment at sequence {}", record.sequence ) })?; let current_policy = active_policy.as_ref().ok_or_else(|| { anyhow!( "transition requires active policy at sequence {}", record.sequence ) })?; ensure_record_commitment(&record, &payload.commitment_id)?; if payload.commitment_id != active.id { return Err(anyhow!( "transition commitment mismatch at sequence {}", record.sequence )); } if payload.runtime_from != runtime_state || payload.commitment_from != active.state { return Err(anyhow!( "transition source mismatch at sequence {}", record.sequence )); } let composite = state_machine::start_transition(runtime_state, active.state.clone()) .with_context(|| { format!("replay transition start at sequence {}", record.sequence) })?; if payload.runtime_to != composite.runtime_state || payload.commitment_to != composite.commitment_state || record.runtime_state != composite.runtime_state { return Err(anyhow!( "transition target mismatch at sequence {}", record.sequence )); } let mut commitment = active.clone(); commitment.state = composite.commitment_state; commitment.transition_policy_id = Some(payload.transition_policy_id.clone()); pending_transition_policy = Some(PendingTransitionPolicy { commitment_id: commitment.id.clone(), policy_id: payload.transition_policy_id, runtime_state: composite.runtime_state, enforcement_level: current_policy.enforcement_level, allowed_context: current_policy.allowed_context.clone(), }); runtime_state = composite.runtime_state; active_commitment = Some(commitment); } EventType::ViolationObserved => { let payload: ViolationObservedPayload = parse_payload(&record, "violation observed")?; ensure_schema_version(payload.schema_version, record.sequence)?; validate_violation_observed_payload(&payload).with_context(|| { format!( "validate violation observed payload at sequence {}", record.sequence ) })?; if runtime_state != RuntimeState::Active || record.runtime_state != RuntimeState::Active { return Err(anyhow!( "violation requires active runtime at sequence {}", record.sequence )); } let commitment_id = record.commitment_id.as_deref().ok_or_else(|| { anyhow!( "violation requires active commitment at sequence {}", record.sequence ) })?; let active = active_commitment.as_ref().ok_or_else(|| { anyhow!( "violation references unknown commitment '{}' at sequence {}", commitment_id, record.sequence ) })?; if active.state != CommitmentState::Active { return Err(anyhow!( "violation requires active commitment at sequence {}", record.sequence )); } if active.id != commitment_id { return Err(anyhow!( "violation commitment mismatch at sequence {}", record.sequence )); } } EventType::EvidenceObserved => { let payload: EvidenceObservedPayload = parse_payload(&record, "evidence observed")?; ensure_schema_version(payload.schema_version, record.sequence)?; validate_evidence_observed_payload(&payload).with_context(|| { format!( "validate evidence observed payload at sequence {}", record.sequence ) })?; if record.runtime_state != runtime_state { return Err(anyhow!( "evidence runtime mismatch at sequence {}", record.sequence )); } if let Some(commitment_id) = record.commitment_id.as_deref() { let active = active_commitment.as_ref().ok_or_else(|| { anyhow!( "evidence references unknown commitment '{}' at sequence {}", commitment_id, record.sequence ) })?; if active.id != commitment_id { return Err(anyhow!( "evidence commitment mismatch at sequence {}", record.sequence )); } } } EventType::ReviewCompleted => { let payload: ReviewCompletedPayload = parse_payload(&record, "review completed")?; ensure_schema_version(payload.schema_version, record.sequence)?; validate_review_completed_payload(&payload).with_context(|| { format!( "validate review completed payload at sequence {}", record.sequence ) })?; if runtime_state != RuntimeState::Review || record.runtime_state != RuntimeState::Review { return Err(anyhow!( "review completion requires review runtime at sequence {}", record.sequence )); } ensure_record_commitment(&record, &payload.commitment_id)?; let commitment = active_commitment.as_ref().ok_or_else(|| { anyhow!( "active reviewed commitment is required for review completion at sequence {}", record.sequence ) })?; if commitment.id != payload.commitment_id { return Err(anyhow!( "review completion commitment mismatch at sequence {}", record.sequence )); } if commitment.state != CommitmentState::Active { return Err(anyhow!( "review completion requires active commitment at sequence {}", record.sequence )); } if review_completed { return Err(anyhow!( "review completion already recorded at sequence {}", record.sequence )); } review_completed = true; } } } if pending_transition_policy.is_some() { return Err(anyhow!( "session replay ended with pending transition policy" )); } if runtime_state == RuntimeState::Review && active_commitment.is_none() { return Err(anyhow!( "session replay ended in review without active reviewed commitment" )); } if active_commitment.is_some() && active_policy.is_none() && runtime_state != RuntimeState::Review { return Err(anyhow!( "session replay ended with active commitment but no active policy" )); } if let (Some(commitment), Some(policy)) = (&active_commitment, &active_policy) { if policy.commitment_id != commitment.id { return Err(anyhow!( "session replay ended with commitment/policy mismatch" )); } if policy.runtime_state != runtime_state { return Err(anyhow!("session replay ended with runtime/policy mismatch")); } } Ok(( runtime_state, active_commitment, active_policy, review_completed, )) } fn apply_pending_transition_policy( record: &EventRecord, payload: PolicyAppliedPayload, expected: PendingTransitionPolicy, runtime_state: &mut RuntimeState, active_commitment: &mut Option, active_policy: &mut Option, ) -> Result<()> { ensure_record_commitment(record, &payload.commitment_id)?; if payload.commitment_id != expected.commitment_id { return Err(anyhow!( "transition policy commitment mismatch at sequence {}", record.sequence )); } if record.runtime_state != expected.runtime_state || payload.runtime_state != expected.runtime_state { return Err(anyhow!( "transition policy runtime mismatch at sequence {}", record.sequence )); } if payload.enforcement_level != expected.enforcement_level { return Err(anyhow!( "transition policy enforcement mismatch at sequence {}", record.sequence )); } if payload.allowed_context != expected.allowed_context { return Err(anyhow!( "transition policy allowed context mismatch at sequence {}", record.sequence )); } if payload.policy_id != expected.policy_id { return Err(anyhow!( "transition policy id mismatch at sequence {}", record.sequence )); } let policy = PolicySnapshot { id: payload.policy_id, commitment_id: payload.commitment_id, schema_version: POLICY_SCHEMA_VERSION, created_at_unix_secs: record.timestamp_unix_secs, runtime_state: payload.runtime_state, enforcement_level: payload.enforcement_level, allowed_context: payload.allowed_context, required_monitors: Vec::new(), violation_actions: Vec::new(), expires_at_unix_secs: None, generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), }; policy .validate() .with_context(|| format!("validate transition policy at sequence {}", record.sequence))?; if let Some(commitment) = active_commitment.as_mut() { if commitment.id != policy.commitment_id { return Err(anyhow!( "transition policy active commitment mismatch at sequence {}", record.sequence )); } commitment.transition_policy_id = Some(policy.id.clone()); } else { return Err(anyhow!( "transition policy references unknown commitment at sequence {}", record.sequence )); } *runtime_state = expected.runtime_state; *active_policy = Some(policy); Ok(()) } fn parse_payload(record: &EventRecord, description: &str) -> Result where T: for<'de> Deserialize<'de>, { serde_json::from_value(record.payload_json.clone()).with_context(|| { format!( "parse {description} payload at sequence {}", record.sequence ) }) } fn ensure_schema_version(schema_version: u16, sequence: u64) -> Result<()> { if schema_version != POLICY_SCHEMA_VERSION { return Err(anyhow!( "unsupported payload schema version at sequence {}: expected {}, found {}", sequence, POLICY_SCHEMA_VERSION, schema_version )); } Ok(()) } fn ensure_record_commitment(record: &EventRecord, commitment_id: &str) -> Result<()> { if record.commitment_id.as_deref() != Some(commitment_id) { return Err(anyhow!( "commitment id mismatch at sequence {}", record.sequence )); } Ok(()) } fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Result<()> { if payload.transition_policy_id.trim().is_empty() { return Err(anyhow!("transition policy id is required")); } if payload.reason.trim().is_empty() { return Err(anyhow!("transition reason is required")); } if payload.return_target.trim().is_empty() { return Err(anyhow!("transition return target is required")); } if payload.expected_duration_secs == 0 { return Err(anyhow!("transition expected duration must be nonzero")); } Ok(()) } fn validate_violation_observed_payload(payload: &ViolationObservedPayload) -> Result<()> { if payload.reason.trim().is_empty() { return Err(anyhow!("violation reason is required")); } Ok(()) } fn validate_evidence_observed_payload(payload: &EvidenceObservedPayload) -> Result<()> { match &payload.health { EvidenceHealth::Available => {} EvidenceHealth::Degraded(reason) | EvidenceHealth::Unavailable(reason) if reason.trim().is_empty() => { return Err(anyhow!("evidence reason is required")); } EvidenceHealth::Degraded(_) | EvidenceHealth::Unavailable(_) => {} } Ok(()) } fn validate_review_completed_payload(payload: &ReviewCompletedPayload) -> Result<()> { if payload.commitment_id.trim().is_empty() { return Err(anyhow!("review commitment id is required")); } if payload.outcome != CommitmentState::Completed { return Err(anyhow!("review outcome must be completed")); } if payload.rating > 3 { return Err(anyhow!("review rating must be between 0 and 3")); } if !payload.rating_f64.is_finite() || !(0.0..=3.0).contains(&payload.rating_f64) { return Err(anyhow!( "review rating_f64 must be finite and between 0 and 3" )); } if payload.rating > 0 && payload.reviewed_window_count == 0 { return Err(anyhow!( "reviewed window count is required when review rating is nonzero" )); } Ok(()) } #[cfg(test)] mod tests { use super::{ CommitmentCreatedPayload, CommitmentTransitionPayload, PolicyAppliedPayload, RuntimeTransitionPayload, TransitionStartedPayload, }; use crate::domain::{ unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, EvidenceHealth, RuntimeState, POLICY_SCHEMA_VERSION, }; use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; use crate::session::SessionController; use serde_json::json; use std::fs; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; fn temp_log_path(name: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir().join(format!( "antidrift-session-{name}-{}-{nonce}.jsonl", std::process::id() )) } fn read_records(path: &PathBuf) -> Vec { fs::read_to_string(path) .unwrap() .lines() .map(|line| serde_json::from_str(line).unwrap()) .collect() } fn commitment_payload(commitment_id: &str, state: CommitmentState) -> serde_json::Value { serde_json::to_value(CommitmentCreatedPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: commitment_id.to_string(), source: CommitmentSource::Manual, next_action: "Implement session controller".to_string(), success_condition: "session tests pass".to_string(), timebox_secs: 1500, state, }) .unwrap() } fn policy_payload(commitment_id: &str) -> serde_json::Value { serde_json::to_value(PolicyAppliedPayload { schema_version: POLICY_SCHEMA_VERSION, policy_id: "policy-test".to_string(), commitment_id: commitment_id.to_string(), runtime_state: RuntimeState::Active, enforcement_level: EnforcementLevel::Warn, allowed_context: AllowedContext::default(), }) .unwrap() } fn transition_policy_payload(commitment_id: &str) -> serde_json::Value { serde_json::to_value(PolicyAppliedPayload { schema_version: POLICY_SCHEMA_VERSION, policy_id: "policy-transition-test".to_string(), commitment_id: commitment_id.to_string(), runtime_state: RuntimeState::Transition, enforcement_level: EnforcementLevel::Warn, allowed_context: AllowedContext::default(), }) .unwrap() } fn transition_started_payload() -> serde_json::Value { serde_json::to_value(TransitionStartedPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: "commitment-test".to_string(), transition_policy_id: "policy-transition-test".to_string(), reason: "Need to inspect failures".to_string(), return_target: "Return to session controller".to_string(), expected_duration_secs: 300, runtime_from: RuntimeState::Active, runtime_to: RuntimeState::Transition, commitment_from: CommitmentState::Active, commitment_to: CommitmentState::Paused, }) .unwrap() } fn append_active_session_with_transition_payload( path: &PathBuf, transition_payload: serde_json::Value, ) { let commitment_id = "commitment-test"; let mut log = EventLog::open(path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::TransitionStarted, runtime_state: RuntimeState::Transition, commitment_id: Some(commitment_id.to_string()), payload_json: transition_payload, }, ]) .unwrap(); } fn append_active_session_with_transition_and_policy_payload( path: &PathBuf, transition_payload: serde_json::Value, policy_payload: serde_json::Value, ) { let commitment_id = "commitment-test"; append_active_session_with_transition_payload(path, transition_payload); let mut log = EventLog::open(path).unwrap(); log.append( EventType::PolicyApplied, RuntimeState::Transition, Some(commitment_id.to_string()), policy_payload, ) .unwrap(); } fn append_review_session_without_completion(path: &PathBuf) { let commitment_id = "commitment-test"; let mut log = EventLog::open(path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "complete_for_review", "from": "active", "to": "review", }), }, ]) .unwrap(); } fn review_completed_payload(commitment_id: &str) -> serde_json::Value { json!({ "schema_version": POLICY_SCHEMA_VERSION, "commitment_id": commitment_id, "outcome": "completed", "rating": 2, "rating_f64": 2.0, "reviewed_window_count": 1, }) } fn commitment_complete_payload(commitment_id: &str) -> serde_json::Value { serde_json::to_value(CommitmentTransitionPayload { schema_version: POLICY_SCHEMA_VERSION, commitment_id: commitment_id.to_string(), action: "complete".to_string(), from: CommitmentState::Active, to: CommitmentState::Completed, }) .unwrap() } fn error_chain_contains(err: &anyhow::Error, expected: &str) -> bool { err.chain() .any(|cause| cause.to_string().contains(expected)) } #[test] fn starts_commitment_and_emits_policy() { let path = temp_log_path("start"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); assert_eq!(session.runtime_state(), RuntimeState::Active); assert_eq!( session.active_policy().unwrap().enforcement_level, EnforcementLevel::Warn ); assert!(session.active_commitment().is_some()); let records = read_records(&path); assert!(records .iter() .any(|record| record.event_type == EventType::CommitmentCreated)); assert!(records .iter() .any(|record| record.event_type == EventType::PolicyApplied)); fs::remove_file(path).unwrap(); } #[test] fn records_violation_with_reason_evidence_and_active_commitment() { let path = temp_log_path("violation-record"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); let commitment_id = session.active_commitment().unwrap().id.clone(); session .record_violation( "Needed to inspect docs", json!({ "message": "Unknown context detected", "window_title": "Browser - unrelated", }), ) .unwrap(); let records = read_records(&path); let violation = records .iter() .find(|record| record.event_type == EventType::ViolationObserved) .unwrap(); assert_eq!(violation.runtime_state, RuntimeState::Active); assert_eq!( violation.commitment_id.as_deref(), Some(commitment_id.as_str()) ); assert_eq!(violation.payload_json["schema_version"], 1); assert_eq!(violation.payload_json["reason"], "Needed to inspect docs"); assert_eq!( violation.payload_json["evidence"]["window_title"], "Browser - unrelated" ); let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Active); assert_eq!( reopened.active_commitment().unwrap().id.as_str(), commitment_id.as_str() ); fs::remove_file(path).unwrap(); } #[test] fn records_evidence_with_health_runtime_and_active_commitment() { let path = temp_log_path("evidence-record"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); let commitment_id = session.active_commitment().unwrap().id.clone(); session .record_evidence(EvidenceHealth::Degraded( "window class unavailable".to_string(), )) .unwrap(); let records = read_records(&path); let evidence = records .iter() .find(|record| record.event_type == EventType::EvidenceObserved) .unwrap(); assert_eq!(evidence.runtime_state, RuntimeState::Active); assert_eq!( evidence.commitment_id.as_deref(), Some(commitment_id.as_str()) ); assert_eq!(evidence.payload_json["schema_version"], 1); assert_eq!( evidence.payload_json["health"], json!({"degraded": "window class unavailable"}) ); let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Active); assert_eq!( reopened.active_commitment().unwrap().id.as_str(), commitment_id.as_str() ); fs::remove_file(path).unwrap(); } #[test] fn replay_ignores_valid_evidence_without_changing_state() { let path = temp_log_path("evidence-replay"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::EvidenceObserved, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "health": {"unavailable": "xdotool active window unavailable"}, }), }, ]) .unwrap(); } let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Active); assert_eq!( reopened.active_commitment().unwrap().id.as_str(), commitment_id ); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_evidence_with_empty_reason() { let path = temp_log_path("evidence-empty-reason"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::EvidenceObserved, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "health": {"degraded": " "}, }), }, ]) .unwrap(); } let err = SessionController::new(&path).expect_err("evidence with empty reason must be rejected"); assert!(error_chain_contains(&err, "evidence reason")); fs::remove_file(path).unwrap(); } #[test] fn record_violation_rejects_empty_reason() { let path = temp_log_path("violation-empty-reason"); let mut session = SessionController::new(&path).unwrap(); let err = session .record_violation(" ", json!({"message": "Unknown context detected"})) .expect_err("empty violation reason must fail"); assert!(err.to_string().contains("reason")); fs::remove_file(path).unwrap(); } #[test] fn record_violation_requires_active_runtime_and_commitment() { let planning_path = temp_log_path("violation-planning"); let mut planning = SessionController::new(&planning_path).unwrap(); planning.enter_planning().unwrap(); let err = planning .record_violation( "Needed to inspect docs", json!({"message": "Unknown context detected"}), ) .expect_err("planning violations must be rejected"); assert!(error_chain_contains(&err, "active")); fs::remove_file(planning_path).unwrap(); let transition_path = temp_log_path("violation-transition"); let mut transition = SessionController::new(&transition_path).unwrap(); transition.enter_planning().unwrap(); transition .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); transition .start_transition( "Need to inspect failures", "Return to session controller", Duration::from_secs(300), ) .unwrap(); let err = transition .record_violation( "Needed to inspect docs", json!({"message": "Unknown context detected"}), ) .expect_err("transition violations must be rejected"); assert!(error_chain_contains(&err, "active")); fs::remove_file(transition_path).unwrap(); let review_path = temp_log_path("violation-review"); let mut review = SessionController::new(&review_path).unwrap(); review.enter_planning().unwrap(); review .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); review.complete_for_review().unwrap(); let err = review .record_violation( "Needed to inspect docs", json!({"message": "Unknown context detected"}), ) .expect_err("review violations must be rejected"); assert!(error_chain_contains(&err, "active")); fs::remove_file(review_path).unwrap(); } #[test] fn replay_rejects_violation_without_active_commitment() { let path = temp_log_path("violation-replay-no-commitment"); { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::ViolationObserved, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "reason": "Needed to inspect docs", "evidence": {"message": "Unknown context detected"}, }), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("planning violation without commitment must be rejected"); assert!(error_chain_contains(&err, "active")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_active_violation_without_commitment_id() { let path = temp_log_path("violation-replay-missing-commitment"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::ViolationObserved, runtime_state: RuntimeState::Active, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "reason": "Needed to inspect docs", "evidence": {"message": "Unknown context detected"}, }), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("active violation without commitment must be rejected"); assert!(error_chain_contains(&err, "active commitment")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_violation_for_wrong_commitment() { let path = temp_log_path("violation-replay-wrong-commitment"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::ViolationObserved, runtime_state: RuntimeState::Active, commitment_id: Some("commitment-other".to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "reason": "Needed to inspect docs", "evidence": {"message": "Unknown context detected"}, }), }, ]) .unwrap(); } let err = SessionController::new(&path).expect_err("wrong violation commitment must be rejected"); assert!(error_chain_contains(&err, "commitment mismatch")); fs::remove_file(path).unwrap(); } #[test] fn activation_batch_emits_adjacent_stable_payloads() { let path = temp_log_path("activation-batch"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); let records = read_records(&path); assert_eq!(records.len(), 3); assert_eq!(records[1].event_type, EventType::CommitmentCreated); assert_eq!(records[2].event_type, EventType::PolicyApplied); assert_eq!(records[2].sequence, records[1].sequence + 1); assert_eq!( records[2].previous_hash.as_deref(), Some(records[1].hash.as_str()) ); let commitment_payload = &records[1].payload_json; assert_eq!(commitment_payload["schema_version"], 1); let commitment_id = records[1].commitment_id.as_deref().unwrap(); assert_eq!(commitment_payload["commitment_id"], commitment_id); assert_eq!(commitment_payload["source"], "manual"); assert_eq!( commitment_payload["next_action"], "Implement session controller" ); assert_eq!( commitment_payload["success_condition"], "session tests pass" ); assert_eq!(commitment_payload["timebox_secs"], 1500); assert_eq!(commitment_payload["state"], "active"); assert!(commitment_payload.get("created_at_unix_secs").is_none()); let policy_payload = &records[2].payload_json; assert_eq!(policy_payload["schema_version"], 1); assert_eq!(policy_payload["commitment_id"], commitment_id); assert_eq!(policy_payload["runtime_state"], "active"); assert_eq!(policy_payload["enforcement_level"], "warn"); assert_eq!( policy_payload["allowed_context"], serde_json::json!({ "window_classes": [], "window_title_substrings": [], "domains": [], "repos": [], "commands": [], }) ); assert!(policy_payload.get("created_at_unix_secs").is_none()); fs::remove_file(path).unwrap(); } #[test] fn hydrates_active_session_from_existing_log() { let path = temp_log_path("hydrate-active"); { let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); } let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Active); assert!(reopened.active_commitment().is_some()); assert!(reopened.active_policy().is_some()); fs::remove_file(path).unwrap(); } #[test] fn completes_review_returns_to_planning_and_starts_next_commitment() { let path = temp_log_path("review-cycle"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); let commitment_id = session.active_commitment().unwrap().id.clone(); session.complete_for_review().unwrap(); assert_eq!(session.runtime_state(), RuntimeState::Review); assert_eq!( session.active_commitment().unwrap().id.as_str(), commitment_id.as_str() ); assert!(session.active_policy().is_none()); session .complete_review_and_return_to_planning(2, 2.0, 1) .unwrap(); assert_eq!(session.runtime_state(), RuntimeState::Planning); session .start_manual_commitment( "Start another session", "second session starts", Duration::from_secs(600), ) .unwrap(); assert_eq!(session.runtime_state(), RuntimeState::Active); assert_eq!( session.active_commitment().unwrap().next_action, "Start another session" ); drop(session); let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Active); assert_eq!( reopened.active_commitment().unwrap().next_action, "Start another session" ); let records = read_records(&path); let review_transition = records .iter() .find(|record| { record.event_type == EventType::RuntimeTransition && record.runtime_state == RuntimeState::Review }) .unwrap(); let payload: RuntimeTransitionPayload = serde_json::from_value(review_transition.payload_json.clone()).unwrap(); assert_eq!(payload.action, "complete_for_review"); assert_eq!(payload.from, RuntimeState::Active); assert_eq!(payload.to, RuntimeState::Review); let review_completed_index = records .iter() .position(|record| record.event_type == EventType::ReviewCompleted) .unwrap(); let commitment_complete_index = records .iter() .position(|record| { record.event_type == EventType::CommitmentTransition && record.payload_json["action"] == "complete" }) .unwrap(); let planning_transition_index = records .iter() .position(|record| { record.event_type == EventType::RuntimeTransition && record.payload_json["action"] == "return_to_planning_after_review" }) .unwrap(); assert_eq!(commitment_complete_index, review_completed_index + 1); assert_eq!(planning_transition_index, commitment_complete_index + 1); let review_completed = &records[review_completed_index]; assert_eq!( review_completed.commitment_id.as_deref(), Some(commitment_id.as_str()) ); assert_eq!( review_completed.payload_json["commitment_id"], commitment_id ); assert_eq!(review_completed.payload_json["outcome"], "completed"); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_review_to_planning_without_review_completed() { let path = temp_log_path("review-missing-completion"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "complete_for_review", "from": "active", "to": "review", }), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: Some(commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "return_to_planning_after_review", "from": "review", "to": "planning", }), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("review to planning without completion must be rejected"); assert!(error_chain_contains(&err, "review completion")); fs::remove_file(path).unwrap(); } #[test] fn replay_accepts_legacy_unlinked_review_to_planning() { let path = temp_log_path("legacy-review-return"); let first_commitment_id = "commitment-legacy-first"; let second_commitment_id = "commitment-legacy-second"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(first_commitment_id.to_string()), payload_json: commitment_payload(first_commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(first_commitment_id.to_string()), payload_json: policy_payload(first_commitment_id), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Review, commitment_id: Some(first_commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "complete_for_review", "from": "active", "to": "review", }), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "return_to_planning_after_review", "from": "review", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(second_commitment_id.to_string()), payload_json: commitment_payload(second_commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(second_commitment_id.to_string()), payload_json: policy_payload(second_commitment_id), }, ]) .unwrap(); } let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Active); assert_eq!( reopened.active_commitment().unwrap().id, second_commitment_id ); fs::remove_file(path).unwrap(); } #[test] fn replay_tolerates_review_completed_without_changing_runtime() { let path = temp_log_path("review-completed-no-transition"); append_review_session_without_completion(&path); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::ReviewCompleted, RuntimeState::Review, Some(commitment_id.to_string()), review_completed_payload(commitment_id), ) .unwrap(); } let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Review); assert_eq!( reopened.active_commitment().unwrap().state, CommitmentState::Active ); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_review_completed_outside_review() { let path = temp_log_path("review-completed-outside-review"); { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::ReviewCompleted, runtime_state: RuntimeState::Planning, commitment_id: Some("commitment-test".to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "commitment_id": "commitment-test", "outcome": "completed", "rating": 2, "rating_f64": 2.0, "reviewed_window_count": 1, }), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("review completion outside review must be rejected"); assert!(error_chain_contains(&err, "review runtime")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_malformed_review_completed() { let path = temp_log_path("review-completed-malformed"); append_review_session_without_completion(&path); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::ReviewCompleted, RuntimeState::Review, Some(commitment_id.to_string()), json!({ "schema_version": POLICY_SCHEMA_VERSION, "commitment_id": commitment_id, "outcome": "completed", "rating": 4, "rating_f64": 4.0, "reviewed_window_count": 1, }), ) .unwrap(); } let err = SessionController::new(&path).expect_err("malformed review completion must fail"); assert!(error_chain_contains(&err, "review rating")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_review_completed_without_commitment_id() { let path = temp_log_path("review-completed-missing-commitment"); append_review_session_without_completion(&path); { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::ReviewCompleted, RuntimeState::Review, None, json!({ "schema_version": POLICY_SCHEMA_VERSION, "outcome": "completed", "rating": 2, "rating_f64": 2.0, "reviewed_window_count": 1, }), ) .unwrap(); } let err = SessionController::new(&path) .expect_err("review completion without commitment id must fail"); assert!(error_chain_contains(&err, "commitment")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_review_completed_for_wrong_commitment() { let path = temp_log_path("review-completed-wrong-commitment"); append_review_session_without_completion(&path); { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::ReviewCompleted, RuntimeState::Review, Some("commitment-other".to_string()), review_completed_payload("commitment-other"), ) .unwrap(); } let err = SessionController::new(&path) .expect_err("wrong review completion commitment must fail"); assert!(error_chain_contains(&err, "commitment mismatch")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_review_completed_with_mismatched_envelope_commitment() { let path = temp_log_path("review-completed-envelope-mismatch"); append_review_session_without_completion(&path); { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::ReviewCompleted, RuntimeState::Review, Some("commitment-other".to_string()), review_completed_payload("commitment-test"), ) .unwrap(); } let err = SessionController::new(&path) .expect_err("review completion envelope mismatch must fail"); assert!(error_chain_contains(&err, "commitment id mismatch")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_duplicate_review_completed() { let path = temp_log_path("review-completed-duplicate"); let commitment_id = "commitment-test"; append_review_session_without_completion(&path); { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::ReviewCompleted, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.to_string()), payload_json: review_completed_payload(commitment_id), }, PendingEventRecord { event_type: EventType::ReviewCompleted, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.to_string()), payload_json: review_completed_payload(commitment_id), }, ]) .unwrap(); } let err = SessionController::new(&path).expect_err("duplicate review completion must fail"); assert!(error_chain_contains(&err, "already recorded")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_review_to_planning_before_commitment_terminal_completion() { let path = temp_log_path("review-planning-before-terminal"); let commitment_id = "commitment-test"; append_review_session_without_completion(&path); { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::ReviewCompleted, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.to_string()), payload_json: review_completed_payload(commitment_id), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: Some(commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "return_to_planning_after_review", "from": "review", "to": "planning", }), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("review to planning before terminal commitment must fail"); assert!(error_chain_contains(&err, "terminal")); fs::remove_file(path).unwrap(); } #[test] fn replay_rejects_commitment_completion_before_review_completed() { let path = temp_log_path("review-terminal-before-completion"); let commitment_id = "commitment-test"; append_review_session_without_completion(&path); { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::CommitmentTransition, RuntimeState::Review, Some(commitment_id.to_string()), commitment_complete_payload(commitment_id), ) .unwrap(); } let err = SessionController::new(&path) .expect_err("commitment completion before review completion must fail"); assert!(error_chain_contains(&err, "review completion")); fs::remove_file(path).unwrap(); } #[test] fn replay_completes_review_commitment_before_returning_to_planning() { let path = temp_log_path("review-complete-terminal"); let commitment_id = "commitment-test"; append_review_session_without_completion(&path); { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::ReviewCompleted, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.to_string()), payload_json: review_completed_payload(commitment_id), }, PendingEventRecord { event_type: EventType::CommitmentTransition, runtime_state: RuntimeState::Review, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_complete_payload(commitment_id), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: Some(commitment_id.to_string()), payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "return_to_planning_after_review", "from": "review", "to": "planning", }), }, ]) .unwrap(); } let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Planning); assert!(reopened.active_commitment().is_none()); assert!(reopened.active_policy().is_none()); fs::remove_file(path).unwrap(); } #[test] fn rejects_standalone_commitment_created_on_replay() { let path = temp_log_path("standalone-commitment"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::CommitmentCreated, RuntimeState::Active, Some(commitment_id.to_string()), commitment_payload(commitment_id, CommitmentState::Active), ) .unwrap(); } let err = SessionController::new(&path) .expect_err("standalone commitment without policy must be rejected"); assert!(error_chain_contains(&err, "planning")); fs::remove_file(path).unwrap(); } #[test] fn rejects_commitment_created_before_planning_transition_on_replay() { let path = temp_log_path("commitment-before-planning"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("commitment activation before planning must be rejected"); assert!(error_chain_contains(&err, "planning")); fs::remove_file(path).unwrap(); } #[test] fn rejects_draft_commitment_before_policy_on_replay() { let path = temp_log_path("draft-commitment-policy"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Draft), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("draft commitment cannot be activated by policy replay"); assert!(error_chain_contains(&err, "commitment activation")); fs::remove_file(path).unwrap(); } #[test] fn rejects_stray_commitment_created_after_active_session_on_replay() { let path = temp_log_path("stray-commitment"); { let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); let mut log = EventLog::open(&path).unwrap(); log.append( EventType::CommitmentCreated, RuntimeState::Active, Some("commitment-stray".to_string()), commitment_payload("commitment-stray", CommitmentState::Active), ) .unwrap(); } let err = SessionController::new(&path) .expect_err("stray commitment must not replace active commitment"); assert!(error_chain_contains(&err, "active commitment")); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_with_wrong_envelope_commitment_on_replay() { let path = temp_log_path("transition-wrong-commitment"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::TransitionStarted, runtime_state: RuntimeState::Transition, commitment_id: Some("commitment-other".to_string()), payload_json: transition_started_payload(), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("transition envelope commitment mismatch must be rejected"); assert!(error_chain_contains(&err, "commitment id mismatch")); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_without_envelope_commitment_on_replay() { let path = temp_log_path("transition-missing-commitment"); let commitment_id = "commitment-test"; { let mut log = EventLog::open(&path).unwrap(); log.append_batch([ PendingEventRecord { event_type: EventType::RuntimeTransition, runtime_state: RuntimeState::Planning, commitment_id: None, payload_json: json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "locked", "to": "planning", }), }, PendingEventRecord { event_type: EventType::CommitmentCreated, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: commitment_payload(commitment_id, CommitmentState::Active), }, PendingEventRecord { event_type: EventType::PolicyApplied, runtime_state: RuntimeState::Active, commitment_id: Some(commitment_id.to_string()), payload_json: policy_payload(commitment_id), }, PendingEventRecord { event_type: EventType::TransitionStarted, runtime_state: RuntimeState::Transition, commitment_id: None, payload_json: transition_started_payload(), }, ]) .unwrap(); } let err = SessionController::new(&path) .expect_err("transition without envelope commitment must be rejected"); assert!(error_chain_contains(&err, "commitment id mismatch")); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_with_empty_reason_on_replay() { let path = temp_log_path("transition-empty-reason"); let mut payload = transition_started_payload(); payload["reason"] = json!(""); append_active_session_with_transition_payload(&path, payload); let err = SessionController::new(&path) .expect_err("transition replay with empty reason must be rejected"); assert!(error_chain_contains(&err, "reason")); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_with_empty_return_target_on_replay() { let path = temp_log_path("transition-empty-return-target"); let mut payload = transition_started_payload(); payload["return_target"] = json!(""); append_active_session_with_transition_payload(&path, payload); let err = SessionController::new(&path) .expect_err("transition replay with empty return target must be rejected"); assert!(error_chain_contains(&err, "return target")); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_with_zero_duration_on_replay() { let path = temp_log_path("transition-zero-duration"); let mut payload = transition_started_payload(); payload["expected_duration_secs"] = json!(0); append_active_session_with_transition_payload(&path, payload); let err = SessionController::new(&path) .expect_err("transition replay with zero duration must be rejected"); assert!(error_chain_contains(&err, "expected duration")); fs::remove_file(path).unwrap(); } #[test] fn reopens_transition_with_transition_policy() { let path = temp_log_path("hydrate-transition"); { let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); session .start_transition( "Need to inspect failures", "Return to session controller", Duration::from_secs(300), ) .unwrap(); } let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Transition); assert_eq!( reopened.active_commitment().unwrap().state, CommitmentState::Paused ); assert!(reopened.active_policy().is_some()); assert_eq!( reopened.active_policy().unwrap().runtime_state, RuntimeState::Transition ); fs::remove_file(path).unwrap(); } #[test] fn returns_from_transition_and_replays_active_state() { let path = temp_log_path("return-transition"); { let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); session .start_transition( "Need to inspect failures", "Return to session controller", Duration::from_secs(300), ) .unwrap(); session.return_from_transition().unwrap(); assert_eq!(session.runtime_state(), RuntimeState::Active); assert_eq!( session.active_commitment().unwrap().state, CommitmentState::Active ); assert_eq!( session.active_policy().unwrap().runtime_state, RuntimeState::Active ); } let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Active); assert_eq!( reopened.active_commitment().unwrap().state, CommitmentState::Active ); assert_eq!( reopened.active_policy().unwrap().runtime_state, RuntimeState::Active ); let records = read_records(&path); assert!(records .iter() .any(|record| record.event_type == EventType::CommitmentTransition)); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_without_policy_on_replay() { let path = temp_log_path("transition-without-policy"); append_active_session_with_transition_payload(&path, transition_started_payload()); let err = SessionController::new(&path) .expect_err("transition started without persisted policy must be rejected"); assert!(error_chain_contains(&err, "pending transition policy")); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_policy_enforcement_mismatch_on_replay() { let path = temp_log_path("transition-policy-enforcement-mismatch"); let commitment_id = "commitment-test"; let mut payload = transition_policy_payload(commitment_id); payload["enforcement_level"] = json!("block"); append_active_session_with_transition_and_policy_payload( &path, transition_started_payload(), payload, ); let err = SessionController::new(&path) .expect_err("transition policy with impossible enforcement must be rejected"); assert!(error_chain_contains(&err, "transition policy enforcement")); fs::remove_file(path).unwrap(); } #[test] fn rejects_non_policy_record_after_transition_started_on_replay() { let path = temp_log_path("transition-interrupted-pair"); append_active_session_with_transition_payload(&path, transition_started_payload()); { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::RuntimeTransition, RuntimeState::Planning, None, json!({ "schema_version": POLICY_SCHEMA_VERSION, "action": "enter_planning", "from": "transition", "to": "planning", }), ) .unwrap(); } let err = SessionController::new(&path).expect_err("transition policy pair must be adjacent"); assert!(error_chain_contains(&err, "expected PolicyApplied")); fs::remove_file(path).unwrap(); } #[test] fn hydrates_transition_from_valid_replay_pair() { let path = temp_log_path("transition-valid-pair"); append_active_session_with_transition_and_policy_payload( &path, transition_started_payload(), transition_policy_payload("commitment-test"), ); let reopened = SessionController::new(&path).unwrap(); assert_eq!(reopened.runtime_state(), RuntimeState::Transition); assert_eq!( reopened.active_commitment().unwrap().state, CommitmentState::Paused ); assert_eq!( reopened.active_policy().unwrap().runtime_state, RuntimeState::Transition ); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_policy_runtime_mismatch_on_replay() { let path = temp_log_path("transition-policy-runtime-mismatch"); let commitment_id = "commitment-test"; append_active_session_with_transition_payload(&path, transition_started_payload()); { let mut log = EventLog::open(&path).unwrap(); log.append( EventType::PolicyApplied, RuntimeState::Active, Some(commitment_id.to_string()), transition_policy_payload(commitment_id), ) .unwrap(); } let err = SessionController::new(&path) .expect_err("transition policy runtime mismatch must be rejected"); assert!(error_chain_contains( &err, "transition policy runtime mismatch" )); fs::remove_file(path).unwrap(); } #[test] fn rejects_extra_transition_policy_after_valid_transition_pair_on_replay() { let path = temp_log_path("transition-extra-policy"); let commitment_id = "commitment-test"; append_active_session_with_transition_and_policy_payload( &path, transition_started_payload(), transition_policy_payload(commitment_id), ); { let mut payload = transition_policy_payload(commitment_id); payload["policy_id"] = json!("policy-transition-extra"); let mut log = EventLog::open(&path).unwrap(); log.append( EventType::PolicyApplied, RuntimeState::Transition, Some(commitment_id.to_string()), payload, ) .unwrap(); } let err = SessionController::new(&path) .expect_err("extra transition policy outside the transition pair must be rejected"); assert!(error_chain_contains( &err, "policy requires active commitment" )); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_missing_policy_id_on_replay() { let path = temp_log_path("transition-missing-policy-id"); let mut payload = transition_started_payload(); payload .as_object_mut() .unwrap() .remove("transition_policy_id"); append_active_session_with_transition_and_policy_payload( &path, payload, transition_policy_payload("commitment-test"), ); let err = SessionController::new(&path) .expect_err("transition policy id is required in transition payload"); assert!(error_chain_contains(&err, "transition_policy_id")); fs::remove_file(path).unwrap(); } #[test] fn rejects_transition_started_empty_policy_id_on_replay() { let path = temp_log_path("transition-empty-policy-id"); let mut payload = transition_started_payload(); payload["transition_policy_id"] = json!(""); append_active_session_with_transition_payload(&path, payload); let err = SessionController::new(&path).expect_err("empty transition policy id must be rejected"); assert!(error_chain_contains(&err, "transition policy id")); fs::remove_file(path).unwrap(); } #[test] fn transition_started_payload_has_stable_schema() { let path = temp_log_path("transition-payload"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); session .start_transition( "Need to inspect failures", "Return to session controller", Duration::from_secs(300), ) .unwrap(); let records = read_records(&path); let transition_index = records .iter() .position(|record| record.event_type == EventType::TransitionStarted) .unwrap(); let transition = &records[transition_index]; let transition_policy = &records[transition_index + 1]; assert_eq!(transition.payload_json["schema_version"], 1); assert_eq!( transition.payload_json["commitment_id"], transition.commitment_id.as_deref().unwrap() ); assert_eq!( transition.payload_json["transition_policy_id"], transition_policy.payload_json["policy_id"] ); assert_eq!( transition.payload_json["reason"], "Need to inspect failures" ); assert_eq!( transition.payload_json["return_target"], "Return to session controller" ); assert_eq!(transition.payload_json["expected_duration_secs"], 300); assert_eq!(transition.payload_json["runtime_from"], "active"); assert_eq!(transition.payload_json["runtime_to"], "transition"); assert_eq!(transition.payload_json["commitment_from"], "active"); assert_eq!(transition.payload_json["commitment_to"], "paused"); assert!(transition.payload_json.get("runtime_state").is_none()); assert!(transition.payload_json.get("commitment_state").is_none()); assert_eq!(transition_policy.event_type, EventType::PolicyApplied); assert_eq!(transition_policy.sequence, transition.sequence + 1); assert_eq!( transition_policy.previous_hash.as_deref(), Some(transition.hash.as_str()) ); assert_eq!(transition_policy.runtime_state, RuntimeState::Transition); assert_eq!(transition_policy.commitment_id, transition.commitment_id); assert_eq!( transition_policy.payload_json["commitment_id"], transition.commitment_id.as_deref().unwrap() ); assert_eq!( transition_policy.payload_json["runtime_state"], "transition" ); assert_eq!(transition_policy.payload_json["enforcement_level"], "warn"); fs::remove_file(path).unwrap(); } #[test] fn transition_requires_reason_and_return_target() { let path = temp_log_path("transition"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); let err = session .start_transition("", "review test results", Duration::from_secs(300)) .expect_err("empty reason must fail"); assert!(err.to_string().contains("reason")); assert_eq!(session.runtime_state(), RuntimeState::Active); let err = session .start_transition("Need to inspect failures", "", Duration::from_secs(300)) .expect_err("empty return target must fail"); assert!(err.to_string().contains("return target")); assert_eq!(session.runtime_state(), RuntimeState::Active); session .start_transition( "Need to inspect failures", "Return to session controller", Duration::from_secs(300), ) .unwrap(); assert_eq!(session.runtime_state(), RuntimeState::Transition); assert_eq!( session.active_commitment().unwrap().state, crate::domain::CommitmentState::Paused ); assert_eq!( session.active_policy().unwrap().runtime_state, RuntimeState::Transition ); fs::remove_file(path).unwrap(); } #[test] fn timing_summary_excludes_completed_transition_interval() { let path = temp_log_path("timing-completed-transition"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); std::thread::sleep(Duration::from_millis(1200)); session .start_transition( "Need to inspect failures", "Return to session controller", Duration::from_secs(300), ) .unwrap(); std::thread::sleep(Duration::from_millis(2200)); session.return_from_transition().unwrap(); let summary = session.timing_summary().unwrap().unwrap(); let created_at = session.active_commitment().unwrap().created_at_unix_secs; let wall_elapsed = unix_secs_now().saturating_sub(created_at); assert_eq!(session.runtime_state(), RuntimeState::Active); assert!(!summary.currently_in_transition); assert!(wall_elapsed >= 3); assert!( summary.active_elapsed_secs <= 2, "active elapsed should exclude completed transition time, got {}", summary.active_elapsed_secs ); fs::remove_file(path).unwrap(); } #[test] fn timing_summary_freezes_elapsed_during_open_transition() { let path = temp_log_path("timing-open-transition"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); std::thread::sleep(Duration::from_millis(1200)); session .start_transition( "Need to inspect failures", "Return to session controller", Duration::from_secs(300), ) .unwrap(); std::thread::sleep(Duration::from_millis(2200)); let summary = session.timing_summary().unwrap().unwrap(); let created_at = session.active_commitment().unwrap().created_at_unix_secs; let wall_elapsed = unix_secs_now().saturating_sub(created_at); assert_eq!(session.runtime_state(), RuntimeState::Transition); assert!(summary.currently_in_transition); assert!(wall_elapsed >= 3); assert!( summary.active_elapsed_secs <= 2, "active elapsed should freeze at transition start, got {}", summary.active_elapsed_secs ); fs::remove_file(path).unwrap(); } #[test] fn timing_summary_stops_active_elapsed_during_review() { let path = temp_log_path("timing-review"); let mut session = SessionController::new(&path).unwrap(); session.enter_planning().unwrap(); session .start_manual_commitment( "Implement session controller", "session tests pass", Duration::from_secs(1500), ) .unwrap(); std::thread::sleep(Duration::from_millis(1200)); session.complete_for_review().unwrap(); std::thread::sleep(Duration::from_millis(2200)); let summary = session.timing_summary().unwrap().unwrap(); let created_at = session.active_commitment().unwrap().created_at_unix_secs; let wall_elapsed = unix_secs_now().saturating_sub(created_at); assert_eq!(session.runtime_state(), RuntimeState::Review); assert!(!summary.currently_in_transition); assert!(wall_elapsed >= 3); assert!( summary.active_elapsed_secs <= 2, "active elapsed should freeze at review start, got {}", summary.active_elapsed_secs ); fs::remove_file(path).unwrap(); } }