diff --git a/src/session.rs b/src/session.rs index b6d9b08..3e847f0 100644 --- a/src/session.rs +++ b/src/session.rs @@ -775,18 +775,23 @@ fn hydrate_session( record.sequence ) })?; - 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 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 + )); + } } } _ => {} @@ -2318,6 +2323,85 @@ mod tests { 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");