diff --git a/src/session.rs b/src/session.rs index 8ed8df1..70678e3 100644 --- a/src/session.rs +++ b/src/session.rs @@ -294,6 +294,18 @@ fn hydrate_session( parse_payload(&record, "commitment created")?; ensure_schema_version(payload.schema_version, record.sequence)?; ensure_record_commitment(&record, &payload.commitment_id)?; + 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, @@ -328,6 +340,12 @@ fn hydrate_session( record.sequence )); } + if commitment.state != CommitmentState::Active { + return Err(anyhow!( + "policy requires active commitment at sequence {}", + record.sequence + )); + } let next = transition_runtime( runtime_state, RuntimeAction::Activate { @@ -408,6 +426,12 @@ fn hydrate_session( } } + if active_commitment.is_some() && active_policy.is_none() { + return Err(anyhow!( + "session replay ended with active commitment but no active policy" + )); + } + Ok((runtime_state, active_commitment, active_policy)) } @@ -447,9 +471,14 @@ fn ensure_record_commitment(record: &EventRecord, commitment_id: &str) -> Result #[cfg(test)] mod tests { - use crate::domain::{EnforcementLevel, RuntimeState}; - use crate::event_log::{EventRecord, EventType}; + use super::{CommitmentCreatedPayload, PolicyAppliedPayload}; + 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}; @@ -473,6 +502,36 @@ mod tests { .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 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"); @@ -590,6 +649,69 @@ mod tests { 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, "active policy")); + 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 transition_started_payload_has_stable_schema() { let path = temp_log_path("transition-payload");