diff --git a/src/session.rs b/src/session.rs index 99009a7..e9e57df 100644 --- a/src/session.rs +++ b/src/session.rs @@ -340,6 +340,12 @@ fn hydrate_session( 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 {}", @@ -951,7 +957,48 @@ mod tests { let err = SessionController::new(&path) .expect_err("standalone commitment without policy must be rejected"); - assert!(error_chain_contains(&err, "active policy")); + 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(); }