Reject invalid session replay

This commit is contained in:
2026-05-25 15:23:45 -04:00
parent 3cece19e73
commit 3cf6ab42b8
+124 -2
View File
@@ -294,6 +294,18 @@ fn hydrate_session(
parse_payload(&record, "commitment created")?; parse_payload(&record, "commitment created")?;
ensure_schema_version(payload.schema_version, record.sequence)?; ensure_schema_version(payload.schema_version, record.sequence)?;
ensure_record_commitment(&record, &payload.commitment_id)?; 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 { let commitment = Commitment {
id: payload.commitment_id, id: payload.commitment_id,
created_at_unix_secs: record.timestamp_unix_secs, created_at_unix_secs: record.timestamp_unix_secs,
@@ -328,6 +340,12 @@ fn hydrate_session(
record.sequence record.sequence
)); ));
} }
if commitment.state != CommitmentState::Active {
return Err(anyhow!(
"policy requires active commitment at sequence {}",
record.sequence
));
}
let next = transition_runtime( let next = transition_runtime(
runtime_state, runtime_state,
RuntimeAction::Activate { 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)) Ok((runtime_state, active_commitment, active_policy))
} }
@@ -447,9 +471,14 @@ fn ensure_record_commitment(record: &EventRecord, commitment_id: &str) -> Result
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::domain::{EnforcementLevel, RuntimeState}; use super::{CommitmentCreatedPayload, PolicyAppliedPayload};
use crate::event_log::{EventRecord, EventType}; use crate::domain::{
AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, RuntimeState,
POLICY_SCHEMA_VERSION,
};
use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord};
use crate::session::SessionController; use crate::session::SessionController;
use serde_json::json;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -473,6 +502,36 @@ mod tests {
.collect() .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] #[test]
fn starts_commitment_and_emits_policy() { fn starts_commitment_and_emits_policy() {
let path = temp_log_path("start"); let path = temp_log_path("start");
@@ -590,6 +649,69 @@ mod tests {
fs::remove_file(path).unwrap(); 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] #[test]
fn transition_started_payload_has_stable_schema() { fn transition_started_payload_has_stable_schema() {
let path = temp_log_path("transition-payload"); let path = temp_log_path("transition-payload");