use crate::domain::{ AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel, 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 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 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, Eq)] struct PendingTransitionPolicy { commitment_id: String, policy_id: String, runtime_state: RuntimeState, enforcement_level: EnforcementLevel, allowed_context: AllowedContext, } #[derive(Debug)] pub struct SessionController { runtime_state: RuntimeState, active_commitment: Option, active_policy: Option, 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) = hydrate_session(&event_log).context("hydrate session from event log")?; Ok(Self { runtime_state, active_commitment, active_policy, 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 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 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(()) } } fn hydrate_session( event_log: &EventLog, ) -> Result<(RuntimeState, Option, Option)> { 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; 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.action != "enter_planning" { return Err(anyhow!( "unsupported runtime transition action '{}' at sequence {}", payload.action, 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 next = transition_runtime(runtime_state, RuntimeAction::EnterPlanning) .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; } 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 commitment.state { CommitmentState::Active => transition_runtime( runtime_state, RuntimeAction::Activate { policy_accepted: true, }, ) .with_context(|| { format!("replay policy application at sequence {}", record.sequence) })?, _ => { 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); } ref unsupported => { return Err(anyhow!( "unsupported session event {:?} at sequence {}", unsupported, record.sequence )); } } } if pending_transition_policy.is_some() { return Err(anyhow!( "session replay ended with pending transition policy" )); } if active_commitment.is_some() && active_policy.is_none() { 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)) } 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(()) } #[cfg(test)] mod tests { use super::{CommitmentCreatedPayload, PolicyAppliedPayload, TransitionStartedPayload}; use crate::domain::{ AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, 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 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 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 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 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(); } }