Tie review completion to commitments

This commit is contained in:
2026-05-26 17:10:39 -04:00
parent 6fd1a61b04
commit 379804caae
2 changed files with 468 additions and 38 deletions
+34 -2
View File
@@ -1680,6 +1680,13 @@ mod tests {
.iter() .iter()
.position(|record| record.event_type == EventType::ReviewCompleted) .position(|record| record.event_type == EventType::ReviewCompleted)
.expect("review completion must be recorded durably"); .expect("review completion must be recorded durably");
let commitment_complete_index = records
.iter()
.position(|record| {
record.event_type == EventType::CommitmentTransition
&& record.payload_json["action"] == "complete"
})
.expect("review completion must complete the commitment");
let planning_transition_index = records let planning_transition_index = records
.iter() .iter()
.position(|record| { .position(|record| {
@@ -1688,7 +1695,16 @@ mod tests {
&& record.payload_json["action"] == "return_to_planning_after_review" && record.payload_json["action"] == "return_to_planning_after_review"
}) })
.expect("review must return to planning after completion"); .expect("review must return to planning after completion");
assert_eq!(planning_transition_index, review_completed_index + 1); assert_eq!(commitment_complete_index, review_completed_index + 1);
assert_eq!(planning_transition_index, commitment_complete_index + 1);
assert_eq!(
records[review_completed_index].commitment_id,
records[commitment_complete_index].commitment_id
);
assert_eq!(
records[review_completed_index].payload_json["outcome"],
"completed"
);
let _ = fs::remove_file(path); let _ = fs::remove_file(path);
} }
@@ -1752,6 +1768,13 @@ mod tests {
.iter() .iter()
.position(|record| record.event_type == EventType::ReviewCompleted) .position(|record| record.event_type == EventType::ReviewCompleted)
.expect("review completion must be recorded"); .expect("review completion must be recorded");
let commitment_complete_index = records
.iter()
.position(|record| {
record.event_type == EventType::CommitmentTransition
&& record.payload_json["action"] == "complete"
})
.expect("review completion must complete the commitment");
let planning_transition_index = records let planning_transition_index = records
.iter() .iter()
.position(|record| { .position(|record| {
@@ -1760,7 +1783,16 @@ mod tests {
&& record.payload_json["action"] == "return_to_planning_after_review" && record.payload_json["action"] == "return_to_planning_after_review"
}) })
.expect("review completion must return to planning"); .expect("review completion must return to planning");
assert_eq!(planning_transition_index, review_completed_index + 1); assert_eq!(commitment_complete_index, review_completed_index + 1);
assert_eq!(planning_transition_index, commitment_complete_index + 1);
assert_eq!(
records[review_completed_index].commitment_id,
records[commitment_complete_index].commitment_id
);
assert_eq!(
records[review_completed_index].payload_json["outcome"],
"completed"
);
let _ = fs::remove_file(path); let _ = fs::remove_file(path);
} }
+425 -27
View File
@@ -88,6 +88,8 @@ struct EvidenceObservedPayload {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct ReviewCompletedPayload { struct ReviewCompletedPayload {
schema_version: u16, schema_version: u16,
commitment_id: String,
outcome: CommitmentState,
rating: u8, rating: u8,
rating_f64: f64, rating_f64: f64,
reviewed_window_count: u32, reviewed_window_count: u32,
@@ -270,13 +272,15 @@ impl SessionController {
let commitment_id = self let commitment_id = self
.active_commitment .active_commitment
.as_ref() .as_ref()
.map(|commitment| commitment.id.clone()); .ok_or_else(|| anyhow!("active commitment is required to complete for review"))?
.id
.clone();
self.event_log self.event_log
.append( .append(
EventType::RuntimeTransition, EventType::RuntimeTransition,
next_runtime_state, next_runtime_state,
commitment_id, Some(commitment_id),
serde_json::to_value(RuntimeTransitionPayload { serde_json::to_value(RuntimeTransitionPayload {
schema_version: POLICY_SCHEMA_VERSION, schema_version: POLICY_SCHEMA_VERSION,
action: "complete_for_review".to_string(), action: "complete_for_review".to_string(),
@@ -288,7 +292,6 @@ impl SessionController {
.context("log runtime transition into review")?; .context("log runtime transition into review")?;
self.runtime_state = next_runtime_state; self.runtime_state = next_runtime_state;
self.active_commitment = None;
self.active_policy = None; self.active_policy = None;
self.review_completed = false; self.review_completed = false;
Ok(()) Ok(())
@@ -304,8 +307,26 @@ impl SessionController {
let next_runtime_state = let next_runtime_state =
transition_runtime(previous_runtime_state, RuntimeAction::ContinuePlanning) transition_runtime(previous_runtime_state, RuntimeAction::ContinuePlanning)
.context("continue planning after review runtime transition")?; .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 { let completion_payload = ReviewCompletedPayload {
schema_version: POLICY_SCHEMA_VERSION, schema_version: POLICY_SCHEMA_VERSION,
commitment_id: commitment_id.clone(),
outcome: CommitmentState::Completed,
rating, rating,
rating_f64, rating_f64,
reviewed_window_count, reviewed_window_count,
@@ -320,11 +341,12 @@ impl SessionController {
}; };
if self.review_completed { if self.review_completed {
if previous_commitment_state == CommitmentState::Completed {
self.event_log self.event_log
.append( .append(
EventType::RuntimeTransition, EventType::RuntimeTransition,
next_runtime_state, next_runtime_state,
None, Some(commitment_id.clone()),
serde_json::to_value(transition_payload) serde_json::to_value(transition_payload)
.context("serialize runtime transition payload")?, .context("serialize runtime transition payload")?,
) )
@@ -333,21 +355,60 @@ impl SessionController {
self.event_log self.event_log
.append_batch([ .append_batch([
PendingEventRecord { PendingEventRecord {
event_type: EventType::ReviewCompleted, event_type: EventType::CommitmentTransition,
runtime_state: RuntimeState::Review, runtime_state: RuntimeState::Review,
commitment_id: None, commitment_id: Some(commitment_id.clone()),
payload_json: serde_json::to_value(completion_payload) payload_json: serde_json::to_value(CommitmentTransitionPayload {
.context("serialize review completed payload")?, 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 { PendingEventRecord {
event_type: EventType::RuntimeTransition, event_type: EventType::RuntimeTransition,
runtime_state: next_runtime_state, runtime_state: next_runtime_state,
commitment_id: None, commitment_id: Some(commitment_id.clone()),
payload_json: serde_json::to_value(transition_payload) payload_json: serde_json::to_value(transition_payload)
.context("serialize runtime transition payload")?, .context("serialize runtime transition payload")?,
}, },
]) ])
.context("log review completion and runtime transition to planning")?; .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.runtime_state = next_runtime_state;
@@ -690,12 +751,39 @@ fn hydrate_session(
)); ));
} }
}; };
if matches!(action, RuntimeAction::ContinuePlanning) && !review_completed { 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
)
})?;
ensure_record_commitment(&record, &commitment.id)?;
if !review_completed {
return Err(anyhow!( return Err(anyhow!(
"review completion is required before returning to planning at sequence {}", "review completion is required before returning to planning at sequence {}",
record.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(|| { let next = transition_runtime(runtime_state, action).with_context(|| {
format!("replay runtime transition at sequence {}", record.sequence) format!("replay runtime transition at sequence {}", record.sequence)
@@ -709,7 +797,6 @@ fn hydrate_session(
runtime_state = next; runtime_state = next;
match action { match action {
RuntimeAction::CompleteForReview => { RuntimeAction::CompleteForReview => {
active_commitment = None;
active_policy = None; active_policy = None;
review_completed = false; review_completed = false;
} }
@@ -753,6 +840,21 @@ fn hydrate_session(
} }
let action = match payload.action.as_str() { let action = match payload.action.as_str() {
"return_from_pause" => CommitmentAction::ReturnFromPause, "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!( return Err(anyhow!(
"unsupported commitment transition action '{}' at sequence {}", "unsupported commitment transition action '{}' at sequence {}",
@@ -1060,9 +1162,22 @@ fn hydrate_session(
record.sequence record.sequence
)); ));
} }
if record.commitment_id.is_some() { 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!( return Err(anyhow!(
"review completion must not reference a commitment at sequence {}", "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 record.sequence
)); ));
} }
@@ -1082,7 +1197,15 @@ fn hydrate_session(
"session replay ended with pending transition policy" "session replay ended with pending transition policy"
)); ));
} }
if active_commitment.is_some() && active_policy.is_none() { 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!( return Err(anyhow!(
"session replay ended with active commitment but no active policy" "session replay ended with active commitment but no active policy"
)); ));
@@ -1254,6 +1377,12 @@ fn validate_evidence_observed_payload(payload: &EvidenceObservedPayload) -> Resu
} }
fn validate_review_completed_payload(payload: &ReviewCompletedPayload) -> Result<()> { 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 { if payload.rating > 3 {
return Err(anyhow!("review rating must be between 0 and 3")); return Err(anyhow!("review rating must be between 0 and 3"));
} }
@@ -1273,8 +1402,8 @@ fn validate_review_completed_payload(payload: &ReviewCompletedPayload) -> Result
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
CommitmentCreatedPayload, PolicyAppliedPayload, RuntimeTransitionPayload, CommitmentCreatedPayload, CommitmentTransitionPayload, PolicyAppliedPayload,
TransitionStartedPayload, RuntimeTransitionPayload, TransitionStartedPayload,
}; };
use crate::domain::{ use crate::domain::{
unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel,
@@ -1458,6 +1587,28 @@ mod tests {
.unwrap(); .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 { fn error_chain_contains(err: &anyhow::Error, expected: &str) -> bool {
err.chain() err.chain()
.any(|cause| cause.to_string().contains(expected)) .any(|cause| cause.to_string().contains(expected))
@@ -2012,9 +2163,15 @@ mod tests {
Duration::from_secs(1500), Duration::from_secs(1500),
) )
.unwrap(); .unwrap();
let commitment_id = session.active_commitment().unwrap().id.clone();
session.complete_for_review().unwrap(); session.complete_for_review().unwrap();
assert_eq!(session.runtime_state(), RuntimeState::Review); 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 session
.complete_review_and_return_to_planning(2, 2.0, 1) .complete_review_and_return_to_planning(2, 2.0, 1)
@@ -2056,6 +2213,38 @@ mod tests {
assert_eq!(payload.from, RuntimeState::Active); assert_eq!(payload.from, RuntimeState::Active);
assert_eq!(payload.to, RuntimeState::Review); 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(); fs::remove_file(path).unwrap();
} }
@@ -2103,7 +2292,7 @@ mod tests {
PendingEventRecord { PendingEventRecord {
event_type: EventType::RuntimeTransition, event_type: EventType::RuntimeTransition,
runtime_state: RuntimeState::Planning, runtime_state: RuntimeState::Planning,
commitment_id: None, commitment_id: Some(commitment_id.to_string()),
payload_json: json!({ payload_json: json!({
"schema_version": POLICY_SCHEMA_VERSION, "schema_version": POLICY_SCHEMA_VERSION,
"action": "return_to_planning_after_review", "action": "return_to_planning_after_review",
@@ -2126,18 +2315,14 @@ mod tests {
fn replay_tolerates_review_completed_without_changing_runtime() { fn replay_tolerates_review_completed_without_changing_runtime() {
let path = temp_log_path("review-completed-no-transition"); let path = temp_log_path("review-completed-no-transition");
append_review_session_without_completion(&path); append_review_session_without_completion(&path);
let commitment_id = "commitment-test";
{ {
let mut log = EventLog::open(&path).unwrap(); let mut log = EventLog::open(&path).unwrap();
log.append( log.append(
EventType::ReviewCompleted, EventType::ReviewCompleted,
RuntimeState::Review, RuntimeState::Review,
None, Some(commitment_id.to_string()),
json!({ review_completed_payload(commitment_id),
"schema_version": POLICY_SCHEMA_VERSION,
"rating": 2,
"rating_f64": 2.25,
"reviewed_window_count": 3,
}),
) )
.unwrap(); .unwrap();
} }
@@ -2145,6 +2330,10 @@ mod tests {
let reopened = SessionController::new(&path).unwrap(); let reopened = SessionController::new(&path).unwrap();
assert_eq!(reopened.runtime_state(), RuntimeState::Review); assert_eq!(reopened.runtime_state(), RuntimeState::Review);
assert_eq!(
reopened.active_commitment().unwrap().state,
CommitmentState::Active
);
fs::remove_file(path).unwrap(); fs::remove_file(path).unwrap();
} }
@@ -2168,9 +2357,11 @@ mod tests {
PendingEventRecord { PendingEventRecord {
event_type: EventType::ReviewCompleted, event_type: EventType::ReviewCompleted,
runtime_state: RuntimeState::Planning, runtime_state: RuntimeState::Planning,
commitment_id: None, commitment_id: Some("commitment-test".to_string()),
payload_json: json!({ payload_json: json!({
"schema_version": POLICY_SCHEMA_VERSION, "schema_version": POLICY_SCHEMA_VERSION,
"commitment_id": "commitment-test",
"outcome": "completed",
"rating": 2, "rating": 2,
"rating_f64": 2.0, "rating_f64": 2.0,
"reviewed_window_count": 1, "reviewed_window_count": 1,
@@ -2191,14 +2382,17 @@ mod tests {
fn replay_rejects_malformed_review_completed() { fn replay_rejects_malformed_review_completed() {
let path = temp_log_path("review-completed-malformed"); let path = temp_log_path("review-completed-malformed");
append_review_session_without_completion(&path); append_review_session_without_completion(&path);
let commitment_id = "commitment-test";
{ {
let mut log = EventLog::open(&path).unwrap(); let mut log = EventLog::open(&path).unwrap();
log.append( log.append(
EventType::ReviewCompleted, EventType::ReviewCompleted,
RuntimeState::Review, RuntimeState::Review,
None, Some(commitment_id.to_string()),
json!({ json!({
"schema_version": POLICY_SCHEMA_VERSION, "schema_version": POLICY_SCHEMA_VERSION,
"commitment_id": commitment_id,
"outcome": "completed",
"rating": 4, "rating": 4,
"rating_f64": 4.0, "rating_f64": 4.0,
"reviewed_window_count": 1, "reviewed_window_count": 1,
@@ -2213,6 +2407,210 @@ mod tests {
fs::remove_file(path).unwrap(); 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] #[test]
fn rejects_standalone_commitment_created_on_replay() { fn rejects_standalone_commitment_created_on_replay() {
let path = temp_log_path("standalone-commitment"); let path = temp_log_path("standalone-commitment");