Files
antidrift/src/session.rs
T

1441 lines
55 KiB
Rust

use crate::domain::{
AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel,
PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION,
};
use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord};
use crate::state_machine::{
self, transition_commitment, transition_runtime, CommitmentAction, RuntimeAction,
};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RuntimeTransitionPayload {
schema_version: u16,
action: String,
from: RuntimeState,
to: RuntimeState,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CommitmentCreatedPayload {
schema_version: u16,
commitment_id: String,
source: CommitmentSource,
next_action: String,
success_condition: String,
timebox_secs: u64,
state: CommitmentState,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PolicyAppliedPayload {
schema_version: u16,
policy_id: String,
commitment_id: String,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct TransitionStartedPayload {
schema_version: u16,
commitment_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
transition_policy_id: Option<String>,
reason: String,
return_target: String,
expected_duration_secs: u64,
runtime_from: RuntimeState,
runtime_to: RuntimeState,
commitment_from: CommitmentState,
commitment_to: CommitmentState,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PendingTransitionPolicy {
commitment_id: String,
policy_id: Option<String>,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
}
#[derive(Debug)]
pub struct SessionController {
runtime_state: RuntimeState,
active_commitment: Option<Commitment>,
active_policy: Option<PolicySnapshot>,
event_log: EventLog,
}
impl SessionController {
pub fn new(event_log_path: impl AsRef<Path>) -> Result<Self> {
let event_log = EventLog::open(event_log_path.as_ref()).with_context(|| {
format!(
"open session event log at {}",
event_log_path.as_ref().display()
)
})?;
let (runtime_state, active_commitment, active_policy) =
hydrate_session(&event_log).context("hydrate session from event log")?;
Ok(Self {
runtime_state,
active_commitment,
active_policy,
event_log,
})
}
pub fn runtime_state(&self) -> RuntimeState {
self.runtime_state
}
pub fn active_policy(&self) -> Option<&PolicySnapshot> {
self.active_policy.as_ref()
}
pub fn active_commitment(&self) -> Option<&Commitment> {
self.active_commitment.as_ref()
}
pub fn enter_planning(&mut self) -> Result<()> {
let previous_runtime_state = self.runtime_state;
let next_runtime_state =
transition_runtime(previous_runtime_state, RuntimeAction::EnterPlanning)
.context("enter planning runtime transition")?;
self.event_log
.append(
EventType::RuntimeTransition,
next_runtime_state,
self.active_commitment
.as_ref()
.map(|commitment| commitment.id.clone()),
serde_json::to_value(RuntimeTransitionPayload {
schema_version: POLICY_SCHEMA_VERSION,
action: "enter_planning".to_string(),
from: previous_runtime_state,
to: next_runtime_state,
})
.context("serialize runtime transition payload")?,
)
.context("log runtime transition into planning")?;
self.runtime_state = next_runtime_state;
Ok(())
}
pub fn start_manual_commitment(
&mut self,
next_action: impl Into<String>,
success_condition: impl Into<String>,
timebox: Duration,
) -> Result<()> {
let previous_runtime_state = self.runtime_state;
let next_runtime_state = transition_runtime(
previous_runtime_state,
RuntimeAction::Activate {
policy_accepted: true,
},
)
.context("activate runtime for manual commitment")?;
let mut commitment = Commitment::new_manual(next_action, success_condition, timebox)
.context("create manual commitment")?;
commitment.state =
transition_commitment(commitment.state.clone(), CommitmentAction::Activate)
.context("activate manual commitment")?;
let policy = PolicySnapshot::try_for_runtime(
commitment.id.clone(),
next_runtime_state,
EnforcementLevel::Warn,
AllowedContext::default(),
)
.context("create active policy snapshot")?;
self.event_log
.append_batch([
PendingEventRecord {
event_type: EventType::CommitmentCreated,
runtime_state: next_runtime_state,
commitment_id: Some(commitment.id.clone()),
payload_json: serde_json::to_value(CommitmentCreatedPayload {
schema_version: POLICY_SCHEMA_VERSION,
commitment_id: commitment.id.clone(),
source: commitment.source.clone(),
next_action: commitment.next_action.clone(),
success_condition: commitment.success_condition.clone(),
timebox_secs: commitment.timebox_secs,
state: commitment.state.clone(),
})
.context("serialize commitment payload")?,
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: next_runtime_state,
commitment_id: Some(commitment.id.clone()),
payload_json: serde_json::to_value(PolicyAppliedPayload {
schema_version: POLICY_SCHEMA_VERSION,
policy_id: policy.id.clone(),
commitment_id: policy.commitment_id.clone(),
runtime_state: policy.runtime_state,
enforcement_level: policy.enforcement_level,
allowed_context: policy.allowed_context.clone(),
})
.context("serialize policy payload")?,
},
])
.context("log commitment activation")?;
self.runtime_state = next_runtime_state;
self.active_commitment = Some(commitment);
self.active_policy = Some(policy);
Ok(())
}
pub fn start_transition(
&mut self,
reason: impl Into<String>,
return_target: impl Into<String>,
expected_duration: Duration,
) -> Result<()> {
let active_commitment = self
.active_commitment
.as_ref()
.ok_or_else(|| anyhow!("active commitment is required to start transition"))?;
let active_policy = self
.active_policy
.as_ref()
.ok_or_else(|| anyhow!("active policy is required to start transition"))?;
let composite =
state_machine::start_transition(self.runtime_state, active_commitment.state.clone())
.context("start transition state change")?;
let previous_commitment_state = active_commitment.state.clone();
let next_commitment_state = composite.commitment_state.clone();
let mut next_commitment = active_commitment.clone();
next_commitment.state = next_commitment_state.clone();
let transition_policy = PolicySnapshot::try_for_runtime(
next_commitment.id.clone(),
composite.runtime_state,
active_policy.enforcement_level,
active_policy.allowed_context.clone(),
)
.context("create transition policy snapshot")?;
next_commitment.transition_policy_id = Some(transition_policy.id.clone());
let transition_payload = TransitionStartedPayload {
schema_version: POLICY_SCHEMA_VERSION,
commitment_id: next_commitment.id.clone(),
transition_policy_id: Some(transition_policy.id.clone()),
reason: reason.into().trim().to_string(),
return_target: return_target.into().trim().to_string(),
expected_duration_secs: expected_duration.as_secs(),
runtime_from: self.runtime_state,
runtime_to: composite.runtime_state,
commitment_from: previous_commitment_state,
commitment_to: next_commitment_state,
};
validate_transition_started_payload(&transition_payload)?;
self.event_log
.append_batch([
PendingEventRecord {
event_type: EventType::TransitionStarted,
runtime_state: composite.runtime_state,
commitment_id: Some(next_commitment.id.clone()),
payload_json: serde_json::to_value(transition_payload)
.context("serialize transition started payload")?,
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: composite.runtime_state,
commitment_id: Some(next_commitment.id.clone()),
payload_json: serde_json::to_value(PolicyAppliedPayload {
schema_version: POLICY_SCHEMA_VERSION,
policy_id: transition_policy.id.clone(),
commitment_id: transition_policy.commitment_id.clone(),
runtime_state: transition_policy.runtime_state,
enforcement_level: transition_policy.enforcement_level,
allowed_context: transition_policy.allowed_context.clone(),
})
.context("serialize transition policy payload")?,
},
])
.context("log transition start")?;
self.runtime_state = composite.runtime_state;
self.active_commitment = Some(next_commitment);
self.active_policy = Some(transition_policy);
Ok(())
}
}
fn hydrate_session(
event_log: &EventLog,
) -> Result<(RuntimeState, Option<Commitment>, Option<PolicySnapshot>)> {
let mut runtime_state = RuntimeState::Locked;
let mut active_commitment: Option<Commitment> = None;
let mut active_policy: Option<PolicySnapshot> = None;
let mut pending_transition_policy: Option<PendingTransitionPolicy> = None;
for record in event_log.records()? {
if pending_transition_policy.is_some() && record.event_type != EventType::PolicyApplied {
return Err(anyhow!(
"pending transition policy expected PolicyApplied at sequence {}",
record.sequence
));
}
match record.event_type {
EventType::RuntimeTransition => {
let payload: RuntimeTransitionPayload =
parse_payload(&record, "runtime transition")?;
ensure_schema_version(payload.schema_version, record.sequence)?;
if payload.action != "enter_planning" {
return Err(anyhow!(
"unsupported runtime transition action '{}' at sequence {}",
payload.action,
record.sequence
));
}
if payload.from != runtime_state {
return Err(anyhow!(
"runtime transition source mismatch at sequence {}: expected {:?}, found {:?}",
record.sequence,
runtime_state,
payload.from
));
}
let next = transition_runtime(runtime_state, RuntimeAction::EnterPlanning)
.with_context(|| {
format!("replay runtime transition at sequence {}", record.sequence)
})?;
if payload.to != next || record.runtime_state != next {
return Err(anyhow!(
"runtime transition target mismatch at sequence {}",
record.sequence
));
}
runtime_state = next;
}
EventType::CommitmentCreated => {
let payload: CommitmentCreatedPayload =
parse_payload(&record, "commitment created")?;
ensure_schema_version(payload.schema_version, record.sequence)?;
ensure_record_commitment(&record, &payload.commitment_id)?;
if active_commitment.is_some() || active_policy.is_some() {
return Err(anyhow!(
"commitment created while active commitment already exists at sequence {}",
record.sequence
));
}
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,
source: payload.source,
project_id: None,
template_id: None,
next_action: payload.next_action,
success_condition: payload.success_condition,
timebox_secs: payload.timebox_secs,
transition_policy_id: None,
state: payload.state,
};
commitment.validate().with_context(|| {
format!("validate commitment at sequence {}", record.sequence)
})?;
active_commitment = Some(commitment);
}
EventType::PolicyApplied => {
let payload: PolicyAppliedPayload = parse_payload(&record, "policy applied")?;
ensure_schema_version(payload.schema_version, record.sequence)?;
if let Some(expected) = pending_transition_policy.take() {
apply_pending_transition_policy(
&record,
payload,
expected,
&mut runtime_state,
&mut active_commitment,
&mut active_policy,
)?;
continue;
}
ensure_record_commitment(&record, &payload.commitment_id)?;
let commitment = active_commitment.as_ref().ok_or_else(|| {
anyhow!(
"policy references unknown commitment '{}' at sequence {}",
payload.commitment_id,
record.sequence
)
})?;
if commitment.id != payload.commitment_id {
return Err(anyhow!(
"policy commitment mismatch at sequence {}",
record.sequence
));
}
let next = match commitment.state {
CommitmentState::Active => transition_runtime(
runtime_state,
RuntimeAction::Activate {
policy_accepted: true,
},
)
.with_context(|| {
format!("replay policy application at sequence {}", record.sequence)
})?,
CommitmentState::Paused
if runtime_state == RuntimeState::Transition
&& payload.runtime_state == RuntimeState::Transition =>
{
RuntimeState::Transition
}
_ => {
return Err(anyhow!(
"policy requires active or transition commitment at sequence {}",
record.sequence
));
}
};
if payload.runtime_state != next || record.runtime_state != next {
return Err(anyhow!(
"policy runtime target mismatch at sequence {}",
record.sequence
));
}
let policy = PolicySnapshot {
id: payload.policy_id,
commitment_id: payload.commitment_id,
schema_version: POLICY_SCHEMA_VERSION,
created_at_unix_secs: record.timestamp_unix_secs,
runtime_state: payload.runtime_state,
enforcement_level: payload.enforcement_level,
allowed_context: payload.allowed_context,
required_monitors: Vec::new(),
violation_actions: Vec::new(),
expires_at_unix_secs: None,
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
};
policy
.validate()
.with_context(|| format!("validate policy at sequence {}", record.sequence))?;
runtime_state = next;
if runtime_state == RuntimeState::Transition {
if let Some(commitment) = active_commitment.as_mut() {
commitment.transition_policy_id = Some(policy.id.clone());
}
}
active_policy = Some(policy);
}
EventType::TransitionStarted => {
let payload: TransitionStartedPayload =
parse_payload(&record, "transition started")?;
ensure_schema_version(payload.schema_version, record.sequence)?;
validate_transition_started_payload(&payload).with_context(|| {
format!(
"validate transition started payload at sequence {}",
record.sequence
)
})?;
let active = active_commitment.as_ref().ok_or_else(|| {
anyhow!(
"transition requires active commitment at sequence {}",
record.sequence
)
})?;
let current_policy = active_policy.as_ref().ok_or_else(|| {
anyhow!(
"transition requires active policy at sequence {}",
record.sequence
)
})?;
ensure_record_commitment(&record, &payload.commitment_id)?;
if payload.commitment_id != active.id {
return Err(anyhow!(
"transition commitment mismatch at sequence {}",
record.sequence
));
}
if payload.runtime_from != runtime_state || payload.commitment_from != active.state
{
return Err(anyhow!(
"transition source mismatch at sequence {}",
record.sequence
));
}
let composite =
state_machine::start_transition(runtime_state, active.state.clone())
.with_context(|| {
format!("replay transition start at sequence {}", record.sequence)
})?;
if payload.runtime_to != composite.runtime_state
|| payload.commitment_to != composite.commitment_state
|| record.runtime_state != composite.runtime_state
{
return Err(anyhow!(
"transition target mismatch at sequence {}",
record.sequence
));
}
let mut commitment = active.clone();
commitment.state = composite.commitment_state;
commitment.transition_policy_id = payload.transition_policy_id.clone();
pending_transition_policy = Some(PendingTransitionPolicy {
commitment_id: commitment.id.clone(),
policy_id: payload.transition_policy_id,
runtime_state: composite.runtime_state,
enforcement_level: current_policy.enforcement_level,
allowed_context: current_policy.allowed_context.clone(),
});
runtime_state = composite.runtime_state;
active_commitment = Some(commitment);
}
ref unsupported => {
return Err(anyhow!(
"unsupported session event {:?} at sequence {}",
unsupported,
record.sequence
));
}
}
}
if pending_transition_policy.is_some() {
return Err(anyhow!(
"session replay ended with pending transition policy"
));
}
if active_commitment.is_some() && active_policy.is_none() {
return Err(anyhow!(
"session replay ended with active commitment but no active policy"
));
}
if let (Some(commitment), Some(policy)) = (&active_commitment, &active_policy) {
if policy.commitment_id != commitment.id {
return Err(anyhow!(
"session replay ended with commitment/policy mismatch"
));
}
if policy.runtime_state != runtime_state {
return Err(anyhow!("session replay ended with runtime/policy mismatch"));
}
}
Ok((runtime_state, active_commitment, active_policy))
}
fn apply_pending_transition_policy(
record: &EventRecord,
payload: PolicyAppliedPayload,
expected: PendingTransitionPolicy,
runtime_state: &mut RuntimeState,
active_commitment: &mut Option<Commitment>,
active_policy: &mut Option<PolicySnapshot>,
) -> Result<()> {
ensure_record_commitment(record, &payload.commitment_id)?;
if payload.commitment_id != expected.commitment_id {
return Err(anyhow!(
"transition policy commitment mismatch at sequence {}",
record.sequence
));
}
if record.runtime_state != expected.runtime_state
|| payload.runtime_state != expected.runtime_state
{
return Err(anyhow!(
"transition policy runtime mismatch at sequence {}",
record.sequence
));
}
if payload.enforcement_level != expected.enforcement_level {
return Err(anyhow!(
"transition policy enforcement mismatch at sequence {}",
record.sequence
));
}
if payload.allowed_context != expected.allowed_context {
return Err(anyhow!(
"transition policy allowed context mismatch at sequence {}",
record.sequence
));
}
if let Some(expected_policy_id) = expected.policy_id.as_ref() {
if payload.policy_id != *expected_policy_id {
return Err(anyhow!(
"transition policy id mismatch at sequence {}",
record.sequence
));
}
}
let policy = PolicySnapshot {
id: payload.policy_id,
commitment_id: payload.commitment_id,
schema_version: POLICY_SCHEMA_VERSION,
created_at_unix_secs: record.timestamp_unix_secs,
runtime_state: payload.runtime_state,
enforcement_level: payload.enforcement_level,
allowed_context: payload.allowed_context,
required_monitors: Vec::new(),
violation_actions: Vec::new(),
expires_at_unix_secs: None,
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
};
policy
.validate()
.with_context(|| format!("validate transition policy at sequence {}", record.sequence))?;
if let Some(commitment) = active_commitment.as_mut() {
if commitment.id != policy.commitment_id {
return Err(anyhow!(
"transition policy active commitment mismatch at sequence {}",
record.sequence
));
}
commitment.transition_policy_id = Some(policy.id.clone());
} else {
return Err(anyhow!(
"transition policy references unknown commitment at sequence {}",
record.sequence
));
}
*runtime_state = expected.runtime_state;
*active_policy = Some(policy);
Ok(())
}
fn parse_payload<T>(record: &EventRecord, description: &str) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
serde_json::from_value(record.payload_json.clone()).with_context(|| {
format!(
"parse {description} payload at sequence {}",
record.sequence
)
})
}
fn ensure_schema_version(schema_version: u16, sequence: u64) -> Result<()> {
if schema_version != POLICY_SCHEMA_VERSION {
return Err(anyhow!(
"unsupported payload schema version at sequence {}: expected {}, found {}",
sequence,
POLICY_SCHEMA_VERSION,
schema_version
));
}
Ok(())
}
fn ensure_record_commitment(record: &EventRecord, commitment_id: &str) -> Result<()> {
if record.commitment_id.as_deref() != Some(commitment_id) {
return Err(anyhow!(
"commitment id mismatch at sequence {}",
record.sequence
));
}
Ok(())
}
fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Result<()> {
if payload.reason.trim().is_empty() {
return Err(anyhow!("transition reason is required"));
}
if payload.return_target.trim().is_empty() {
return Err(anyhow!("transition return target is required"));
}
if payload.expected_duration_secs == 0 {
return Err(anyhow!("transition expected duration must be nonzero"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{CommitmentCreatedPayload, PolicyAppliedPayload, TransitionStartedPayload};
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};
fn temp_log_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"antidrift-session-{name}-{}-{nonce}.jsonl",
std::process::id()
))
}
fn read_records(path: &PathBuf) -> Vec<EventRecord> {
fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.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 transition_policy_payload(commitment_id: &str) -> serde_json::Value {
serde_json::to_value(PolicyAppliedPayload {
schema_version: POLICY_SCHEMA_VERSION,
policy_id: "policy-transition-test".to_string(),
commitment_id: commitment_id.to_string(),
runtime_state: RuntimeState::Transition,
enforcement_level: EnforcementLevel::Warn,
allowed_context: AllowedContext::default(),
})
.unwrap()
}
fn transition_started_payload() -> serde_json::Value {
serde_json::to_value(TransitionStartedPayload {
schema_version: POLICY_SCHEMA_VERSION,
commitment_id: "commitment-test".to_string(),
transition_policy_id: Some("policy-transition-test".to_string()),
reason: "Need to inspect failures".to_string(),
return_target: "Return to session controller".to_string(),
expected_duration_secs: 300,
runtime_from: RuntimeState::Active,
runtime_to: RuntimeState::Transition,
commitment_from: CommitmentState::Active,
commitment_to: CommitmentState::Paused,
})
.unwrap()
}
fn append_active_session_with_transition_payload(
path: &PathBuf,
transition_payload: serde_json::Value,
) {
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::Active),
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: RuntimeState::Active,
commitment_id: Some(commitment_id.to_string()),
payload_json: policy_payload(commitment_id),
},
PendingEventRecord {
event_type: EventType::TransitionStarted,
runtime_state: RuntimeState::Transition,
commitment_id: Some(commitment_id.to_string()),
payload_json: transition_payload,
},
])
.unwrap();
}
fn append_active_session_with_transition_and_policy_payload(
path: &PathBuf,
transition_payload: serde_json::Value,
policy_payload: serde_json::Value,
) {
let commitment_id = "commitment-test";
append_active_session_with_transition_payload(path, transition_payload);
let mut log = EventLog::open(path).unwrap();
log.append(
EventType::PolicyApplied,
RuntimeState::Transition,
Some(commitment_id.to_string()),
policy_payload,
)
.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");
let mut session = SessionController::new(&path).unwrap();
session.enter_planning().unwrap();
session
.start_manual_commitment(
"Implement session controller",
"session tests pass",
Duration::from_secs(1500),
)
.unwrap();
assert_eq!(session.runtime_state(), RuntimeState::Active);
assert_eq!(
session.active_policy().unwrap().enforcement_level,
EnforcementLevel::Warn
);
assert!(session.active_commitment().is_some());
let records = read_records(&path);
assert!(records
.iter()
.any(|record| record.event_type == EventType::CommitmentCreated));
assert!(records
.iter()
.any(|record| record.event_type == EventType::PolicyApplied));
fs::remove_file(path).unwrap();
}
#[test]
fn activation_batch_emits_adjacent_stable_payloads() {
let path = temp_log_path("activation-batch");
let mut session = SessionController::new(&path).unwrap();
session.enter_planning().unwrap();
session
.start_manual_commitment(
"Implement session controller",
"session tests pass",
Duration::from_secs(1500),
)
.unwrap();
let records = read_records(&path);
assert_eq!(records.len(), 3);
assert_eq!(records[1].event_type, EventType::CommitmentCreated);
assert_eq!(records[2].event_type, EventType::PolicyApplied);
assert_eq!(records[2].sequence, records[1].sequence + 1);
assert_eq!(
records[2].previous_hash.as_deref(),
Some(records[1].hash.as_str())
);
let commitment_payload = &records[1].payload_json;
assert_eq!(commitment_payload["schema_version"], 1);
let commitment_id = records[1].commitment_id.as_deref().unwrap();
assert_eq!(commitment_payload["commitment_id"], commitment_id);
assert_eq!(commitment_payload["source"], "manual");
assert_eq!(
commitment_payload["next_action"],
"Implement session controller"
);
assert_eq!(
commitment_payload["success_condition"],
"session tests pass"
);
assert_eq!(commitment_payload["timebox_secs"], 1500);
assert_eq!(commitment_payload["state"], "active");
assert!(commitment_payload.get("created_at_unix_secs").is_none());
let policy_payload = &records[2].payload_json;
assert_eq!(policy_payload["schema_version"], 1);
assert_eq!(policy_payload["commitment_id"], commitment_id);
assert_eq!(policy_payload["runtime_state"], "active");
assert_eq!(policy_payload["enforcement_level"], "warn");
assert_eq!(
policy_payload["allowed_context"],
serde_json::json!({
"window_classes": [],
"window_title_substrings": [],
"domains": [],
"repos": [],
"commands": [],
})
);
assert!(policy_payload.get("created_at_unix_secs").is_none());
fs::remove_file(path).unwrap();
}
#[test]
fn hydrates_active_session_from_existing_log() {
let path = temp_log_path("hydrate-active");
{
let mut session = SessionController::new(&path).unwrap();
session.enter_planning().unwrap();
session
.start_manual_commitment(
"Implement session controller",
"session tests pass",
Duration::from_secs(1500),
)
.unwrap();
}
let reopened = SessionController::new(&path).unwrap();
assert_eq!(reopened.runtime_state(), RuntimeState::Active);
assert!(reopened.active_commitment().is_some());
assert!(reopened.active_policy().is_some());
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 rejects_stray_commitment_created_after_active_session_on_replay() {
let path = temp_log_path("stray-commitment");
{
let mut session = SessionController::new(&path).unwrap();
session.enter_planning().unwrap();
session
.start_manual_commitment(
"Implement session controller",
"session tests pass",
Duration::from_secs(1500),
)
.unwrap();
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::CommitmentCreated,
RuntimeState::Active,
Some("commitment-stray".to_string()),
commitment_payload("commitment-stray", CommitmentState::Active),
)
.unwrap();
}
let err = SessionController::new(&path)
.expect_err("stray commitment must not replace active commitment");
assert!(error_chain_contains(&err, "active commitment"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_with_wrong_envelope_commitment_on_replay() {
let path = temp_log_path("transition-wrong-commitment");
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::Active),
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: RuntimeState::Active,
commitment_id: Some(commitment_id.to_string()),
payload_json: policy_payload(commitment_id),
},
PendingEventRecord {
event_type: EventType::TransitionStarted,
runtime_state: RuntimeState::Transition,
commitment_id: Some("commitment-other".to_string()),
payload_json: transition_started_payload(),
},
])
.unwrap();
}
let err = SessionController::new(&path)
.expect_err("transition envelope commitment mismatch must be rejected");
assert!(error_chain_contains(&err, "commitment id mismatch"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_without_envelope_commitment_on_replay() {
let path = temp_log_path("transition-missing-commitment");
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::Active),
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: RuntimeState::Active,
commitment_id: Some(commitment_id.to_string()),
payload_json: policy_payload(commitment_id),
},
PendingEventRecord {
event_type: EventType::TransitionStarted,
runtime_state: RuntimeState::Transition,
commitment_id: None,
payload_json: transition_started_payload(),
},
])
.unwrap();
}
let err = SessionController::new(&path)
.expect_err("transition without envelope commitment must be rejected");
assert!(error_chain_contains(&err, "commitment id mismatch"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_with_empty_reason_on_replay() {
let path = temp_log_path("transition-empty-reason");
let mut payload = transition_started_payload();
payload["reason"] = json!("");
append_active_session_with_transition_payload(&path, payload);
let err = SessionController::new(&path)
.expect_err("transition replay with empty reason must be rejected");
assert!(error_chain_contains(&err, "reason"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_with_empty_return_target_on_replay() {
let path = temp_log_path("transition-empty-return-target");
let mut payload = transition_started_payload();
payload["return_target"] = json!("");
append_active_session_with_transition_payload(&path, payload);
let err = SessionController::new(&path)
.expect_err("transition replay with empty return target must be rejected");
assert!(error_chain_contains(&err, "return target"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_with_zero_duration_on_replay() {
let path = temp_log_path("transition-zero-duration");
let mut payload = transition_started_payload();
payload["expected_duration_secs"] = json!(0);
append_active_session_with_transition_payload(&path, payload);
let err = SessionController::new(&path)
.expect_err("transition replay with zero duration must be rejected");
assert!(error_chain_contains(&err, "expected duration"));
fs::remove_file(path).unwrap();
}
#[test]
fn reopens_transition_with_transition_policy() {
let path = temp_log_path("hydrate-transition");
{
let mut session = SessionController::new(&path).unwrap();
session.enter_planning().unwrap();
session
.start_manual_commitment(
"Implement session controller",
"session tests pass",
Duration::from_secs(1500),
)
.unwrap();
session
.start_transition(
"Need to inspect failures",
"Return to session controller",
Duration::from_secs(300),
)
.unwrap();
}
let reopened = SessionController::new(&path).unwrap();
assert_eq!(reopened.runtime_state(), RuntimeState::Transition);
assert_eq!(
reopened.active_commitment().unwrap().state,
CommitmentState::Paused
);
assert!(reopened.active_policy().is_some());
assert_eq!(
reopened.active_policy().unwrap().runtime_state,
RuntimeState::Transition
);
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_without_policy_on_replay() {
let path = temp_log_path("transition-without-policy");
append_active_session_with_transition_payload(&path, transition_started_payload());
let err = SessionController::new(&path)
.expect_err("transition started without persisted policy must be rejected");
assert!(error_chain_contains(&err, "pending transition policy"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_policy_enforcement_mismatch_on_replay() {
let path = temp_log_path("transition-policy-enforcement-mismatch");
let commitment_id = "commitment-test";
let mut payload = transition_policy_payload(commitment_id);
payload["enforcement_level"] = json!("block");
append_active_session_with_transition_and_policy_payload(
&path,
transition_started_payload(),
payload,
);
let err = SessionController::new(&path)
.expect_err("transition policy with impossible enforcement must be rejected");
assert!(error_chain_contains(&err, "transition policy enforcement"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_non_policy_record_after_transition_started_on_replay() {
let path = temp_log_path("transition-interrupted-pair");
append_active_session_with_transition_payload(&path, transition_started_payload());
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Planning,
None,
json!({
"schema_version": POLICY_SCHEMA_VERSION,
"action": "enter_planning",
"from": "transition",
"to": "planning",
}),
)
.unwrap();
}
let err =
SessionController::new(&path).expect_err("transition policy pair must be adjacent");
assert!(error_chain_contains(&err, "expected PolicyApplied"));
fs::remove_file(path).unwrap();
}
#[test]
fn hydrates_transition_from_valid_replay_pair() {
let path = temp_log_path("transition-valid-pair");
append_active_session_with_transition_and_policy_payload(
&path,
transition_started_payload(),
transition_policy_payload("commitment-test"),
);
let reopened = SessionController::new(&path).unwrap();
assert_eq!(reopened.runtime_state(), RuntimeState::Transition);
assert_eq!(
reopened.active_commitment().unwrap().state,
CommitmentState::Paused
);
assert_eq!(
reopened.active_policy().unwrap().runtime_state,
RuntimeState::Transition
);
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_policy_runtime_mismatch_on_replay() {
let path = temp_log_path("transition-policy-runtime-mismatch");
let commitment_id = "commitment-test";
append_active_session_with_transition_payload(&path, transition_started_payload());
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::PolicyApplied,
RuntimeState::Active,
Some(commitment_id.to_string()),
transition_policy_payload(commitment_id),
)
.unwrap();
}
let err = SessionController::new(&path)
.expect_err("transition policy runtime mismatch must be rejected");
assert!(error_chain_contains(
&err,
"transition policy runtime mismatch"
));
fs::remove_file(path).unwrap();
}
#[test]
fn transition_started_payload_has_stable_schema() {
let path = temp_log_path("transition-payload");
let mut session = SessionController::new(&path).unwrap();
session.enter_planning().unwrap();
session
.start_manual_commitment(
"Implement session controller",
"session tests pass",
Duration::from_secs(1500),
)
.unwrap();
session
.start_transition(
"Need to inspect failures",
"Return to session controller",
Duration::from_secs(300),
)
.unwrap();
let records = read_records(&path);
let transition_index = records
.iter()
.position(|record| record.event_type == EventType::TransitionStarted)
.unwrap();
let transition = &records[transition_index];
let transition_policy = &records[transition_index + 1];
assert_eq!(transition.payload_json["schema_version"], 1);
assert_eq!(
transition.payload_json["commitment_id"],
transition.commitment_id.as_deref().unwrap()
);
assert_eq!(
transition.payload_json["transition_policy_id"],
transition_policy.payload_json["policy_id"]
);
assert_eq!(
transition.payload_json["reason"],
"Need to inspect failures"
);
assert_eq!(
transition.payload_json["return_target"],
"Return to session controller"
);
assert_eq!(transition.payload_json["expected_duration_secs"], 300);
assert_eq!(transition.payload_json["runtime_from"], "active");
assert_eq!(transition.payload_json["runtime_to"], "transition");
assert_eq!(transition.payload_json["commitment_from"], "active");
assert_eq!(transition.payload_json["commitment_to"], "paused");
assert!(transition.payload_json.get("runtime_state").is_none());
assert!(transition.payload_json.get("commitment_state").is_none());
assert_eq!(transition_policy.event_type, EventType::PolicyApplied);
assert_eq!(transition_policy.sequence, transition.sequence + 1);
assert_eq!(
transition_policy.previous_hash.as_deref(),
Some(transition.hash.as_str())
);
assert_eq!(transition_policy.runtime_state, RuntimeState::Transition);
assert_eq!(transition_policy.commitment_id, transition.commitment_id);
assert_eq!(
transition_policy.payload_json["commitment_id"],
transition.commitment_id.as_deref().unwrap()
);
assert_eq!(
transition_policy.payload_json["runtime_state"],
"transition"
);
assert_eq!(transition_policy.payload_json["enforcement_level"], "warn");
fs::remove_file(path).unwrap();
}
#[test]
fn transition_requires_reason_and_return_target() {
let path = temp_log_path("transition");
let mut session = SessionController::new(&path).unwrap();
session.enter_planning().unwrap();
session
.start_manual_commitment(
"Implement session controller",
"session tests pass",
Duration::from_secs(1500),
)
.unwrap();
let err = session
.start_transition("", "review test results", Duration::from_secs(300))
.expect_err("empty reason must fail");
assert!(err.to_string().contains("reason"));
assert_eq!(session.runtime_state(), RuntimeState::Active);
let err = session
.start_transition("Need to inspect failures", "", Duration::from_secs(300))
.expect_err("empty return target must fail");
assert!(err.to_string().contains("return target"));
assert_eq!(session.runtime_state(), RuntimeState::Active);
session
.start_transition(
"Need to inspect failures",
"Return to session controller",
Duration::from_secs(300),
)
.unwrap();
assert_eq!(session.runtime_state(), RuntimeState::Transition);
assert_eq!(
session.active_commitment().unwrap().state,
crate::domain::CommitmentState::Paused
);
assert_eq!(
session.active_policy().unwrap().runtime_state,
RuntimeState::Transition
);
fs::remove_file(path).unwrap();
}
}