3da269c3b7
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3476 lines
135 KiB
Rust
3476 lines
135 KiB
Rust
use crate::domain::{
|
|
unix_secs_now, AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel,
|
|
EvidenceHealth, 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 serde_json::Value;
|
|
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 CommitmentTransitionPayload {
|
|
schema_version: u16,
|
|
commitment_id: String,
|
|
action: String,
|
|
from: CommitmentState,
|
|
to: CommitmentState,
|
|
}
|
|
|
|
#[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,
|
|
transition_policy_id: 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, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct ViolationObservedPayload {
|
|
schema_version: u16,
|
|
reason: String,
|
|
evidence: Value,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct EvidenceObservedPayload {
|
|
schema_version: u16,
|
|
health: EvidenceHealth,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct ReviewCompletedPayload {
|
|
schema_version: u16,
|
|
commitment_id: String,
|
|
outcome: CommitmentState,
|
|
rating: u8,
|
|
rating_f64: f64,
|
|
reviewed_window_count: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct PendingTransitionPolicy {
|
|
commitment_id: String,
|
|
policy_id: String,
|
|
runtime_state: RuntimeState,
|
|
enforcement_level: EnforcementLevel,
|
|
allowed_context: AllowedContext,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct SessionTimingSummary {
|
|
pub active_elapsed_secs: u64,
|
|
pub currently_in_transition: bool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SessionController {
|
|
runtime_state: RuntimeState,
|
|
active_commitment: Option<Commitment>,
|
|
active_policy: Option<PolicySnapshot>,
|
|
review_completed: bool,
|
|
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, review_completed) =
|
|
hydrate_session(&event_log).context("hydrate session from event log")?;
|
|
|
|
Ok(Self {
|
|
runtime_state,
|
|
active_commitment,
|
|
active_policy,
|
|
review_completed,
|
|
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 timing_summary(&self) -> Result<Option<SessionTimingSummary>> {
|
|
let Some(commitment) = self.active_commitment.as_ref() else {
|
|
return Ok(None);
|
|
};
|
|
self.timing_summary_at(commitment, unix_secs_now())
|
|
}
|
|
|
|
fn timing_summary_at(
|
|
&self,
|
|
commitment: &Commitment,
|
|
now_unix_secs: u64,
|
|
) -> Result<Option<SessionTimingSummary>> {
|
|
let commitment_id = commitment.id.as_str();
|
|
let mut active_elapsed_secs = 0_u64;
|
|
let mut active_started_at: Option<u64> = None;
|
|
let mut open_transition_started_at: Option<u64> = None;
|
|
let mut saw_commitment = false;
|
|
|
|
for record in self.event_log.records()? {
|
|
if record.commitment_id.as_deref() != Some(commitment_id) {
|
|
continue;
|
|
}
|
|
|
|
match record.event_type {
|
|
EventType::CommitmentCreated => {
|
|
let payload: CommitmentCreatedPayload =
|
|
parse_payload(&record, "commitment created")?;
|
|
if payload.commitment_id == commitment_id {
|
|
active_started_at = Some(record.timestamp_unix_secs);
|
|
open_transition_started_at = None;
|
|
saw_commitment = true;
|
|
}
|
|
}
|
|
EventType::TransitionStarted => {
|
|
let payload: TransitionStartedPayload =
|
|
parse_payload(&record, "transition started")?;
|
|
if payload.commitment_id == commitment_id
|
|
&& open_transition_started_at.is_none()
|
|
{
|
|
if let Some(started_at) = active_started_at.take() {
|
|
active_elapsed_secs = active_elapsed_secs.saturating_add(
|
|
record.timestamp_unix_secs.saturating_sub(started_at),
|
|
);
|
|
}
|
|
open_transition_started_at = Some(record.timestamp_unix_secs);
|
|
}
|
|
}
|
|
EventType::RuntimeTransition => {
|
|
let payload: RuntimeTransitionPayload =
|
|
parse_payload(&record, "runtime transition")?;
|
|
if payload.action == "complete_for_review" {
|
|
if let Some(started_at) = active_started_at.take() {
|
|
active_elapsed_secs = active_elapsed_secs.saturating_add(
|
|
record.timestamp_unix_secs.saturating_sub(started_at),
|
|
);
|
|
}
|
|
}
|
|
if payload.action == "return_to_active"
|
|
&& open_transition_started_at.take().is_some()
|
|
{
|
|
active_started_at = Some(record.timestamp_unix_secs);
|
|
}
|
|
}
|
|
EventType::CommitmentTransition => {
|
|
let payload: CommitmentTransitionPayload =
|
|
parse_payload(&record, "commitment transition")?;
|
|
if payload.commitment_id == commitment_id
|
|
&& payload.action == "return_from_pause"
|
|
&& open_transition_started_at.take().is_some()
|
|
{
|
|
active_started_at = Some(record.timestamp_unix_secs);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if !saw_commitment {
|
|
return Ok(None);
|
|
}
|
|
|
|
if let Some(started_at) = active_started_at {
|
|
active_elapsed_secs =
|
|
active_elapsed_secs.saturating_add(now_unix_secs.saturating_sub(started_at));
|
|
}
|
|
|
|
Ok(Some(SessionTimingSummary {
|
|
active_elapsed_secs,
|
|
currently_in_transition: self.runtime_state == RuntimeState::Transition
|
|
&& open_transition_started_at.is_some(),
|
|
}))
|
|
}
|
|
|
|
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 complete_for_review(&mut self) -> Result<()> {
|
|
let previous_runtime_state = self.runtime_state;
|
|
let next_runtime_state =
|
|
transition_runtime(previous_runtime_state, RuntimeAction::CompleteForReview)
|
|
.context("complete active session for review runtime transition")?;
|
|
let commitment_id = self
|
|
.active_commitment
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("active commitment is required to complete for review"))?
|
|
.id
|
|
.clone();
|
|
|
|
self.event_log
|
|
.append(
|
|
EventType::RuntimeTransition,
|
|
next_runtime_state,
|
|
Some(commitment_id),
|
|
serde_json::to_value(RuntimeTransitionPayload {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
action: "complete_for_review".to_string(),
|
|
from: previous_runtime_state,
|
|
to: next_runtime_state,
|
|
})
|
|
.context("serialize runtime transition payload")?,
|
|
)
|
|
.context("log runtime transition into review")?;
|
|
|
|
self.runtime_state = next_runtime_state;
|
|
self.active_policy = None;
|
|
self.review_completed = false;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn complete_review_and_return_to_planning(
|
|
&mut self,
|
|
rating: u8,
|
|
rating_f64: f64,
|
|
reviewed_window_count: u32,
|
|
) -> Result<()> {
|
|
let previous_runtime_state = self.runtime_state;
|
|
let next_runtime_state =
|
|
transition_runtime(previous_runtime_state, RuntimeAction::ContinuePlanning)
|
|
.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 {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
commitment_id: commitment_id.clone(),
|
|
outcome: CommitmentState::Completed,
|
|
rating,
|
|
rating_f64,
|
|
reviewed_window_count,
|
|
};
|
|
validate_review_completed_payload(&completion_payload)
|
|
.context("validate review completed payload")?;
|
|
let transition_payload = RuntimeTransitionPayload {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
action: "return_to_planning_after_review".to_string(),
|
|
from: previous_runtime_state,
|
|
to: next_runtime_state,
|
|
};
|
|
|
|
if self.review_completed {
|
|
if previous_commitment_state == CommitmentState::Completed {
|
|
self.event_log
|
|
.append(
|
|
EventType::RuntimeTransition,
|
|
next_runtime_state,
|
|
Some(commitment_id.clone()),
|
|
serde_json::to_value(transition_payload)
|
|
.context("serialize runtime transition payload")?,
|
|
)
|
|
.context("log runtime transition from review to planning")?;
|
|
} else {
|
|
self.event_log
|
|
.append_batch([
|
|
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 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.active_commitment = None;
|
|
self.active_policy = None;
|
|
self.review_completed = false;
|
|
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: 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(())
|
|
}
|
|
|
|
pub fn return_from_transition(&mut self) -> Result<()> {
|
|
let active_commitment = self
|
|
.active_commitment
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("active commitment is required to return from transition"))?;
|
|
let active_policy = self
|
|
.active_policy
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("active policy is required to return from transition"))?;
|
|
|
|
let previous_runtime_state = self.runtime_state;
|
|
let next_runtime_state =
|
|
transition_runtime(previous_runtime_state, RuntimeAction::ReturnToActive)
|
|
.context("return from transition runtime transition")?;
|
|
|
|
let previous_commitment_state = active_commitment.state.clone();
|
|
let next_commitment_state = transition_commitment(
|
|
previous_commitment_state.clone(),
|
|
CommitmentAction::ReturnFromPause,
|
|
)
|
|
.context("return paused commitment to active")?;
|
|
|
|
let mut next_commitment = active_commitment.clone();
|
|
next_commitment.state = next_commitment_state.clone();
|
|
next_commitment.transition_policy_id = None;
|
|
|
|
let active_policy = PolicySnapshot::try_for_runtime(
|
|
next_commitment.id.clone(),
|
|
next_runtime_state,
|
|
active_policy.enforcement_level,
|
|
active_policy.allowed_context.clone(),
|
|
)
|
|
.context("create returned active policy snapshot")?;
|
|
|
|
self.event_log
|
|
.append_batch([
|
|
PendingEventRecord {
|
|
event_type: EventType::RuntimeTransition,
|
|
runtime_state: next_runtime_state,
|
|
commitment_id: Some(next_commitment.id.clone()),
|
|
payload_json: serde_json::to_value(RuntimeTransitionPayload {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
action: "return_to_active".to_string(),
|
|
from: previous_runtime_state,
|
|
to: next_runtime_state,
|
|
})
|
|
.context("serialize return runtime transition payload")?,
|
|
},
|
|
PendingEventRecord {
|
|
event_type: EventType::CommitmentTransition,
|
|
runtime_state: next_runtime_state,
|
|
commitment_id: Some(next_commitment.id.clone()),
|
|
payload_json: serde_json::to_value(CommitmentTransitionPayload {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
commitment_id: next_commitment.id.clone(),
|
|
action: "return_from_pause".to_string(),
|
|
from: previous_commitment_state,
|
|
to: next_commitment_state,
|
|
})
|
|
.context("serialize return commitment transition payload")?,
|
|
},
|
|
PendingEventRecord {
|
|
event_type: EventType::PolicyApplied,
|
|
runtime_state: next_runtime_state,
|
|
commitment_id: Some(next_commitment.id.clone()),
|
|
payload_json: serde_json::to_value(PolicyAppliedPayload {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
policy_id: active_policy.id.clone(),
|
|
commitment_id: active_policy.commitment_id.clone(),
|
|
runtime_state: active_policy.runtime_state,
|
|
enforcement_level: active_policy.enforcement_level,
|
|
allowed_context: active_policy.allowed_context.clone(),
|
|
})
|
|
.context("serialize returned active policy payload")?,
|
|
},
|
|
])
|
|
.context("log return from transition")?;
|
|
|
|
self.runtime_state = next_runtime_state;
|
|
self.active_commitment = Some(next_commitment);
|
|
self.active_policy = Some(active_policy);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn record_violation(&mut self, reason: impl Into<String>, evidence: Value) -> Result<()> {
|
|
let payload = ViolationObservedPayload {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
reason: reason.into().trim().to_string(),
|
|
evidence,
|
|
};
|
|
validate_violation_observed_payload(&payload)?;
|
|
if self.runtime_state != RuntimeState::Active {
|
|
return Err(anyhow!("violation requires active runtime"));
|
|
}
|
|
let commitment_id = self
|
|
.active_commitment
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("violation requires active commitment"))?
|
|
.id
|
|
.clone();
|
|
|
|
self.event_log
|
|
.append(
|
|
EventType::ViolationObserved,
|
|
RuntimeState::Active,
|
|
Some(commitment_id),
|
|
serde_json::to_value(payload).context("serialize violation observed payload")?,
|
|
)
|
|
.context("log violation observation")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn record_evidence(&mut self, health: EvidenceHealth) -> Result<()> {
|
|
let payload = EvidenceObservedPayload {
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
health,
|
|
};
|
|
validate_evidence_observed_payload(&payload)?;
|
|
let runtime_state = self.runtime_state;
|
|
let commitment_id = self
|
|
.active_commitment
|
|
.as_ref()
|
|
.map(|commitment| commitment.id.clone());
|
|
|
|
self.event_log
|
|
.append(
|
|
EventType::EvidenceObserved,
|
|
runtime_state,
|
|
commitment_id,
|
|
serde_json::to_value(payload).context("serialize evidence observed payload")?,
|
|
)
|
|
.context("log evidence observation")?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn hydrate_session(
|
|
event_log: &EventLog,
|
|
) -> Result<(
|
|
RuntimeState,
|
|
Option<Commitment>,
|
|
Option<PolicySnapshot>,
|
|
bool,
|
|
)> {
|
|
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;
|
|
let mut review_completed = false;
|
|
|
|
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.from != runtime_state {
|
|
return Err(anyhow!(
|
|
"runtime transition source mismatch at sequence {}: expected {:?}, found {:?}",
|
|
record.sequence,
|
|
runtime_state,
|
|
payload.from
|
|
));
|
|
}
|
|
let action = match payload.action.as_str() {
|
|
"enter_planning" => RuntimeAction::EnterPlanning,
|
|
"complete_for_review" => RuntimeAction::CompleteForReview,
|
|
"return_to_active" => RuntimeAction::ReturnToActive,
|
|
"return_to_planning_after_review" => RuntimeAction::ContinuePlanning,
|
|
_ => {
|
|
return Err(anyhow!(
|
|
"unsupported runtime transition action '{}' at sequence {}",
|
|
payload.action,
|
|
record.sequence
|
|
));
|
|
}
|
|
};
|
|
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
|
|
)
|
|
})?;
|
|
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
|
|
));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let next = transition_runtime(runtime_state, action).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;
|
|
match action {
|
|
RuntimeAction::CompleteForReview => {
|
|
active_policy = None;
|
|
review_completed = false;
|
|
}
|
|
RuntimeAction::ContinuePlanning => {
|
|
active_commitment = None;
|
|
active_policy = None;
|
|
review_completed = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
EventType::CommitmentTransition => {
|
|
let payload: CommitmentTransitionPayload =
|
|
parse_payload(&record, "commitment transition")?;
|
|
ensure_schema_version(payload.schema_version, record.sequence)?;
|
|
ensure_record_commitment(&record, &payload.commitment_id)?;
|
|
if record.runtime_state != runtime_state {
|
|
return Err(anyhow!(
|
|
"commitment transition runtime mismatch at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
let commitment = active_commitment.as_mut().ok_or_else(|| {
|
|
anyhow!(
|
|
"commitment transition references unknown commitment '{}' at sequence {}",
|
|
payload.commitment_id,
|
|
record.sequence
|
|
)
|
|
})?;
|
|
if commitment.id != payload.commitment_id {
|
|
return Err(anyhow!(
|
|
"commitment transition commitment mismatch at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
if commitment.state != payload.from {
|
|
return Err(anyhow!(
|
|
"commitment transition source mismatch at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
let action = match payload.action.as_str() {
|
|
"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!(
|
|
"unsupported commitment transition action '{}' at sequence {}",
|
|
payload.action,
|
|
record.sequence
|
|
));
|
|
}
|
|
};
|
|
let next =
|
|
transition_commitment(commitment.state.clone(), action).with_context(|| {
|
|
format!(
|
|
"replay commitment transition at sequence {}",
|
|
record.sequence
|
|
)
|
|
})?;
|
|
if payload.to != next {
|
|
return Err(anyhow!(
|
|
"commitment transition target mismatch at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
commitment.state = next;
|
|
if commitment.state == CommitmentState::Active {
|
|
commitment.transition_policy_id = None;
|
|
}
|
|
}
|
|
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 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 {}",
|
|
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 (runtime_state, &commitment.state, payload.runtime_state) {
|
|
(RuntimeState::Planning, CommitmentState::Active, RuntimeState::Active) => {
|
|
transition_runtime(
|
|
runtime_state,
|
|
RuntimeAction::Activate {
|
|
policy_accepted: true,
|
|
},
|
|
)
|
|
.with_context(|| {
|
|
format!("replay policy application at sequence {}", record.sequence)
|
|
})?
|
|
}
|
|
(RuntimeState::Active, CommitmentState::Active, RuntimeState::Active) => {
|
|
RuntimeState::Active
|
|
}
|
|
_ => {
|
|
return Err(anyhow!(
|
|
"policy requires active 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 = Some(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);
|
|
}
|
|
EventType::ViolationObserved => {
|
|
let payload: ViolationObservedPayload =
|
|
parse_payload(&record, "violation observed")?;
|
|
ensure_schema_version(payload.schema_version, record.sequence)?;
|
|
validate_violation_observed_payload(&payload).with_context(|| {
|
|
format!(
|
|
"validate violation observed payload at sequence {}",
|
|
record.sequence
|
|
)
|
|
})?;
|
|
if runtime_state != RuntimeState::Active
|
|
|| record.runtime_state != RuntimeState::Active
|
|
{
|
|
return Err(anyhow!(
|
|
"violation requires active runtime at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
let commitment_id = record.commitment_id.as_deref().ok_or_else(|| {
|
|
anyhow!(
|
|
"violation requires active commitment at sequence {}",
|
|
record.sequence
|
|
)
|
|
})?;
|
|
let active = active_commitment.as_ref().ok_or_else(|| {
|
|
anyhow!(
|
|
"violation references unknown commitment '{}' at sequence {}",
|
|
commitment_id,
|
|
record.sequence
|
|
)
|
|
})?;
|
|
if active.state != CommitmentState::Active {
|
|
return Err(anyhow!(
|
|
"violation requires active commitment at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
if active.id != commitment_id {
|
|
return Err(anyhow!(
|
|
"violation commitment mismatch at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
}
|
|
EventType::EvidenceObserved => {
|
|
let payload: EvidenceObservedPayload = parse_payload(&record, "evidence observed")?;
|
|
ensure_schema_version(payload.schema_version, record.sequence)?;
|
|
validate_evidence_observed_payload(&payload).with_context(|| {
|
|
format!(
|
|
"validate evidence observed payload at sequence {}",
|
|
record.sequence
|
|
)
|
|
})?;
|
|
if record.runtime_state != runtime_state {
|
|
return Err(anyhow!(
|
|
"evidence runtime mismatch at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
if let Some(commitment_id) = record.commitment_id.as_deref() {
|
|
let active = active_commitment.as_ref().ok_or_else(|| {
|
|
anyhow!(
|
|
"evidence references unknown commitment '{}' at sequence {}",
|
|
commitment_id,
|
|
record.sequence
|
|
)
|
|
})?;
|
|
if active.id != commitment_id {
|
|
return Err(anyhow!(
|
|
"evidence commitment mismatch at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
}
|
|
}
|
|
EventType::ReviewCompleted => {
|
|
let payload: ReviewCompletedPayload = parse_payload(&record, "review completed")?;
|
|
ensure_schema_version(payload.schema_version, record.sequence)?;
|
|
validate_review_completed_payload(&payload).with_context(|| {
|
|
format!(
|
|
"validate review completed payload at sequence {}",
|
|
record.sequence
|
|
)
|
|
})?;
|
|
if runtime_state != RuntimeState::Review
|
|
|| record.runtime_state != RuntimeState::Review
|
|
{
|
|
return Err(anyhow!(
|
|
"review completion requires review runtime at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
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!(
|
|
"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
|
|
));
|
|
}
|
|
if review_completed {
|
|
return Err(anyhow!(
|
|
"review completion already recorded at sequence {}",
|
|
record.sequence
|
|
));
|
|
}
|
|
review_completed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if pending_transition_policy.is_some() {
|
|
return Err(anyhow!(
|
|
"session replay ended with pending transition policy"
|
|
));
|
|
}
|
|
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!(
|
|
"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,
|
|
review_completed,
|
|
))
|
|
}
|
|
|
|
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 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.transition_policy_id.trim().is_empty() {
|
|
return Err(anyhow!("transition policy id is required"));
|
|
}
|
|
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(())
|
|
}
|
|
|
|
fn validate_violation_observed_payload(payload: &ViolationObservedPayload) -> Result<()> {
|
|
if payload.reason.trim().is_empty() {
|
|
return Err(anyhow!("violation reason is required"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_evidence_observed_payload(payload: &EvidenceObservedPayload) -> Result<()> {
|
|
match &payload.health {
|
|
EvidenceHealth::Available => {}
|
|
EvidenceHealth::Degraded(reason) | EvidenceHealth::Unavailable(reason)
|
|
if reason.trim().is_empty() =>
|
|
{
|
|
return Err(anyhow!("evidence reason is required"));
|
|
}
|
|
EvidenceHealth::Degraded(_) | EvidenceHealth::Unavailable(_) => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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 {
|
|
return Err(anyhow!("review rating must be between 0 and 3"));
|
|
}
|
|
if !payload.rating_f64.is_finite() || !(0.0..=3.0).contains(&payload.rating_f64) {
|
|
return Err(anyhow!(
|
|
"review rating_f64 must be finite and between 0 and 3"
|
|
));
|
|
}
|
|
if payload.rating > 0 && payload.reviewed_window_count == 0 {
|
|
return Err(anyhow!(
|
|
"reviewed window count is required when review rating is nonzero"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
CommitmentCreatedPayload, CommitmentTransitionPayload, PolicyAppliedPayload,
|
|
RuntimeTransitionPayload, TransitionStartedPayload,
|
|
};
|
|
use crate::domain::{
|
|
unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel,
|
|
EvidenceHealth, 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: "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 append_review_session_without_completion(path: &PathBuf) {
|
|
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::RuntimeTransition,
|
|
runtime_state: RuntimeState::Review,
|
|
commitment_id: Some(commitment_id.to_string()),
|
|
payload_json: json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"action": "complete_for_review",
|
|
"from": "active",
|
|
"to": "review",
|
|
}),
|
|
},
|
|
])
|
|
.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 {
|
|
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 records_violation_with_reason_evidence_and_active_commitment() {
|
|
let path = temp_log_path("violation-record");
|
|
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 commitment_id = session.active_commitment().unwrap().id.clone();
|
|
|
|
session
|
|
.record_violation(
|
|
"Needed to inspect docs",
|
|
json!({
|
|
"message": "Unknown context detected",
|
|
"window_title": "Browser - unrelated",
|
|
}),
|
|
)
|
|
.unwrap();
|
|
|
|
let records = read_records(&path);
|
|
let violation = records
|
|
.iter()
|
|
.find(|record| record.event_type == EventType::ViolationObserved)
|
|
.unwrap();
|
|
assert_eq!(violation.runtime_state, RuntimeState::Active);
|
|
assert_eq!(
|
|
violation.commitment_id.as_deref(),
|
|
Some(commitment_id.as_str())
|
|
);
|
|
assert_eq!(violation.payload_json["schema_version"], 1);
|
|
assert_eq!(violation.payload_json["reason"], "Needed to inspect docs");
|
|
assert_eq!(
|
|
violation.payload_json["evidence"]["window_title"],
|
|
"Browser - unrelated"
|
|
);
|
|
|
|
let reopened = SessionController::new(&path).unwrap();
|
|
assert_eq!(reopened.runtime_state(), RuntimeState::Active);
|
|
assert_eq!(
|
|
reopened.active_commitment().unwrap().id.as_str(),
|
|
commitment_id.as_str()
|
|
);
|
|
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn records_evidence_with_health_runtime_and_active_commitment() {
|
|
let path = temp_log_path("evidence-record");
|
|
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 commitment_id = session.active_commitment().unwrap().id.clone();
|
|
|
|
session
|
|
.record_evidence(EvidenceHealth::Degraded(
|
|
"window class unavailable".to_string(),
|
|
))
|
|
.unwrap();
|
|
|
|
let records = read_records(&path);
|
|
let evidence = records
|
|
.iter()
|
|
.find(|record| record.event_type == EventType::EvidenceObserved)
|
|
.unwrap();
|
|
assert_eq!(evidence.runtime_state, RuntimeState::Active);
|
|
assert_eq!(
|
|
evidence.commitment_id.as_deref(),
|
|
Some(commitment_id.as_str())
|
|
);
|
|
assert_eq!(evidence.payload_json["schema_version"], 1);
|
|
assert_eq!(
|
|
evidence.payload_json["health"],
|
|
json!({"degraded": "window class unavailable"})
|
|
);
|
|
|
|
let reopened = SessionController::new(&path).unwrap();
|
|
assert_eq!(reopened.runtime_state(), RuntimeState::Active);
|
|
assert_eq!(
|
|
reopened.active_commitment().unwrap().id.as_str(),
|
|
commitment_id.as_str()
|
|
);
|
|
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_ignores_valid_evidence_without_changing_state() {
|
|
let path = temp_log_path("evidence-replay");
|
|
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::EvidenceObserved,
|
|
runtime_state: RuntimeState::Active,
|
|
commitment_id: Some(commitment_id.to_string()),
|
|
payload_json: json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"health": {"unavailable": "xdotool active window unavailable"},
|
|
}),
|
|
},
|
|
])
|
|
.unwrap();
|
|
}
|
|
|
|
let reopened = SessionController::new(&path).unwrap();
|
|
|
|
assert_eq!(reopened.runtime_state(), RuntimeState::Active);
|
|
assert_eq!(
|
|
reopened.active_commitment().unwrap().id.as_str(),
|
|
commitment_id
|
|
);
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_evidence_with_empty_reason() {
|
|
let path = temp_log_path("evidence-empty-reason");
|
|
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::EvidenceObserved,
|
|
runtime_state: RuntimeState::Active,
|
|
commitment_id: Some(commitment_id.to_string()),
|
|
payload_json: json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"health": {"degraded": " "},
|
|
}),
|
|
},
|
|
])
|
|
.unwrap();
|
|
}
|
|
|
|
let err =
|
|
SessionController::new(&path).expect_err("evidence with empty reason must be rejected");
|
|
|
|
assert!(error_chain_contains(&err, "evidence reason"));
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn record_violation_rejects_empty_reason() {
|
|
let path = temp_log_path("violation-empty-reason");
|
|
let mut session = SessionController::new(&path).unwrap();
|
|
|
|
let err = session
|
|
.record_violation(" ", json!({"message": "Unknown context detected"}))
|
|
.expect_err("empty violation reason must fail");
|
|
|
|
assert!(err.to_string().contains("reason"));
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn record_violation_requires_active_runtime_and_commitment() {
|
|
let planning_path = temp_log_path("violation-planning");
|
|
let mut planning = SessionController::new(&planning_path).unwrap();
|
|
planning.enter_planning().unwrap();
|
|
|
|
let err = planning
|
|
.record_violation(
|
|
"Needed to inspect docs",
|
|
json!({"message": "Unknown context detected"}),
|
|
)
|
|
.expect_err("planning violations must be rejected");
|
|
assert!(error_chain_contains(&err, "active"));
|
|
fs::remove_file(planning_path).unwrap();
|
|
|
|
let transition_path = temp_log_path("violation-transition");
|
|
let mut transition = SessionController::new(&transition_path).unwrap();
|
|
transition.enter_planning().unwrap();
|
|
transition
|
|
.start_manual_commitment(
|
|
"Implement session controller",
|
|
"session tests pass",
|
|
Duration::from_secs(1500),
|
|
)
|
|
.unwrap();
|
|
transition
|
|
.start_transition(
|
|
"Need to inspect failures",
|
|
"Return to session controller",
|
|
Duration::from_secs(300),
|
|
)
|
|
.unwrap();
|
|
|
|
let err = transition
|
|
.record_violation(
|
|
"Needed to inspect docs",
|
|
json!({"message": "Unknown context detected"}),
|
|
)
|
|
.expect_err("transition violations must be rejected");
|
|
assert!(error_chain_contains(&err, "active"));
|
|
fs::remove_file(transition_path).unwrap();
|
|
|
|
let review_path = temp_log_path("violation-review");
|
|
let mut review = SessionController::new(&review_path).unwrap();
|
|
review.enter_planning().unwrap();
|
|
review
|
|
.start_manual_commitment(
|
|
"Implement session controller",
|
|
"session tests pass",
|
|
Duration::from_secs(1500),
|
|
)
|
|
.unwrap();
|
|
review.complete_for_review().unwrap();
|
|
|
|
let err = review
|
|
.record_violation(
|
|
"Needed to inspect docs",
|
|
json!({"message": "Unknown context detected"}),
|
|
)
|
|
.expect_err("review violations must be rejected");
|
|
assert!(error_chain_contains(&err, "active"));
|
|
fs::remove_file(review_path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_violation_without_active_commitment() {
|
|
let path = temp_log_path("violation-replay-no-commitment");
|
|
{
|
|
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::ViolationObserved,
|
|
runtime_state: RuntimeState::Planning,
|
|
commitment_id: None,
|
|
payload_json: json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"reason": "Needed to inspect docs",
|
|
"evidence": {"message": "Unknown context detected"},
|
|
}),
|
|
},
|
|
])
|
|
.unwrap();
|
|
}
|
|
|
|
let err = SessionController::new(&path)
|
|
.expect_err("planning violation without commitment must be rejected");
|
|
|
|
assert!(error_chain_contains(&err, "active"));
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_active_violation_without_commitment_id() {
|
|
let path = temp_log_path("violation-replay-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::ViolationObserved,
|
|
runtime_state: RuntimeState::Active,
|
|
commitment_id: None,
|
|
payload_json: json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"reason": "Needed to inspect docs",
|
|
"evidence": {"message": "Unknown context detected"},
|
|
}),
|
|
},
|
|
])
|
|
.unwrap();
|
|
}
|
|
|
|
let err = SessionController::new(&path)
|
|
.expect_err("active violation without commitment must be rejected");
|
|
|
|
assert!(error_chain_contains(&err, "active commitment"));
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_violation_for_wrong_commitment() {
|
|
let path = temp_log_path("violation-replay-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::ViolationObserved,
|
|
runtime_state: RuntimeState::Active,
|
|
commitment_id: Some("commitment-other".to_string()),
|
|
payload_json: json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"reason": "Needed to inspect docs",
|
|
"evidence": {"message": "Unknown context detected"},
|
|
}),
|
|
},
|
|
])
|
|
.unwrap();
|
|
}
|
|
|
|
let err =
|
|
SessionController::new(&path).expect_err("wrong violation commitment must be rejected");
|
|
|
|
assert!(error_chain_contains(&err, "commitment mismatch"));
|
|
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 completes_review_returns_to_planning_and_starts_next_commitment() {
|
|
let path = temp_log_path("review-cycle");
|
|
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 commitment_id = session.active_commitment().unwrap().id.clone();
|
|
|
|
session.complete_for_review().unwrap();
|
|
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
|
|
.complete_review_and_return_to_planning(2, 2.0, 1)
|
|
.unwrap();
|
|
assert_eq!(session.runtime_state(), RuntimeState::Planning);
|
|
|
|
session
|
|
.start_manual_commitment(
|
|
"Start another session",
|
|
"second session starts",
|
|
Duration::from_secs(600),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(session.runtime_state(), RuntimeState::Active);
|
|
assert_eq!(
|
|
session.active_commitment().unwrap().next_action,
|
|
"Start another session"
|
|
);
|
|
|
|
drop(session);
|
|
let reopened = SessionController::new(&path).unwrap();
|
|
assert_eq!(reopened.runtime_state(), RuntimeState::Active);
|
|
assert_eq!(
|
|
reopened.active_commitment().unwrap().next_action,
|
|
"Start another session"
|
|
);
|
|
|
|
let records = read_records(&path);
|
|
let review_transition = records
|
|
.iter()
|
|
.find(|record| {
|
|
record.event_type == EventType::RuntimeTransition
|
|
&& record.runtime_state == RuntimeState::Review
|
|
})
|
|
.unwrap();
|
|
let payload: RuntimeTransitionPayload =
|
|
serde_json::from_value(review_transition.payload_json.clone()).unwrap();
|
|
assert_eq!(payload.action, "complete_for_review");
|
|
assert_eq!(payload.from, RuntimeState::Active);
|
|
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();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_review_to_planning_without_review_completed() {
|
|
let path = temp_log_path("review-missing-completion");
|
|
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::RuntimeTransition,
|
|
runtime_state: RuntimeState::Review,
|
|
commitment_id: Some(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: 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 without completion must be rejected");
|
|
|
|
assert!(error_chain_contains(&err, "review completion"));
|
|
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");
|
|
append_review_session_without_completion(&path);
|
|
let commitment_id = "commitment-test";
|
|
{
|
|
let mut log = EventLog::open(&path).unwrap();
|
|
log.append(
|
|
EventType::ReviewCompleted,
|
|
RuntimeState::Review,
|
|
Some(commitment_id.to_string()),
|
|
review_completed_payload(commitment_id),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let reopened = SessionController::new(&path).unwrap();
|
|
|
|
assert_eq!(reopened.runtime_state(), RuntimeState::Review);
|
|
assert_eq!(
|
|
reopened.active_commitment().unwrap().state,
|
|
CommitmentState::Active
|
|
);
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_review_completed_outside_review() {
|
|
let path = temp_log_path("review-completed-outside-review");
|
|
{
|
|
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::ReviewCompleted,
|
|
runtime_state: RuntimeState::Planning,
|
|
commitment_id: Some("commitment-test".to_string()),
|
|
payload_json: json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"commitment_id": "commitment-test",
|
|
"outcome": "completed",
|
|
"rating": 2,
|
|
"rating_f64": 2.0,
|
|
"reviewed_window_count": 1,
|
|
}),
|
|
},
|
|
])
|
|
.unwrap();
|
|
}
|
|
|
|
let err = SessionController::new(&path)
|
|
.expect_err("review completion outside review must be rejected");
|
|
|
|
assert!(error_chain_contains(&err, "review runtime"));
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_malformed_review_completed() {
|
|
let path = temp_log_path("review-completed-malformed");
|
|
append_review_session_without_completion(&path);
|
|
let commitment_id = "commitment-test";
|
|
{
|
|
let mut log = EventLog::open(&path).unwrap();
|
|
log.append(
|
|
EventType::ReviewCompleted,
|
|
RuntimeState::Review,
|
|
Some(commitment_id.to_string()),
|
|
json!({
|
|
"schema_version": POLICY_SCHEMA_VERSION,
|
|
"commitment_id": commitment_id,
|
|
"outcome": "completed",
|
|
"rating": 4,
|
|
"rating_f64": 4.0,
|
|
"reviewed_window_count": 1,
|
|
}),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let err = SessionController::new(&path).expect_err("malformed review completion must fail");
|
|
|
|
assert!(error_chain_contains(&err, "review rating"));
|
|
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]
|
|
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, "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();
|
|
}
|
|
|
|
#[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 returns_from_transition_and_replays_active_state() {
|
|
let path = temp_log_path("return-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();
|
|
session.return_from_transition().unwrap();
|
|
|
|
assert_eq!(session.runtime_state(), RuntimeState::Active);
|
|
assert_eq!(
|
|
session.active_commitment().unwrap().state,
|
|
CommitmentState::Active
|
|
);
|
|
assert_eq!(
|
|
session.active_policy().unwrap().runtime_state,
|
|
RuntimeState::Active
|
|
);
|
|
}
|
|
|
|
let reopened = SessionController::new(&path).unwrap();
|
|
assert_eq!(reopened.runtime_state(), RuntimeState::Active);
|
|
assert_eq!(
|
|
reopened.active_commitment().unwrap().state,
|
|
CommitmentState::Active
|
|
);
|
|
assert_eq!(
|
|
reopened.active_policy().unwrap().runtime_state,
|
|
RuntimeState::Active
|
|
);
|
|
|
|
let records = read_records(&path);
|
|
assert!(records
|
|
.iter()
|
|
.any(|record| record.event_type == EventType::CommitmentTransition));
|
|
|
|
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 rejects_extra_transition_policy_after_valid_transition_pair_on_replay() {
|
|
let path = temp_log_path("transition-extra-policy");
|
|
let commitment_id = "commitment-test";
|
|
append_active_session_with_transition_and_policy_payload(
|
|
&path,
|
|
transition_started_payload(),
|
|
transition_policy_payload(commitment_id),
|
|
);
|
|
{
|
|
let mut payload = transition_policy_payload(commitment_id);
|
|
payload["policy_id"] = json!("policy-transition-extra");
|
|
let mut log = EventLog::open(&path).unwrap();
|
|
log.append(
|
|
EventType::PolicyApplied,
|
|
RuntimeState::Transition,
|
|
Some(commitment_id.to_string()),
|
|
payload,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let err = SessionController::new(&path)
|
|
.expect_err("extra transition policy outside the transition pair must be rejected");
|
|
|
|
assert!(error_chain_contains(
|
|
&err,
|
|
"policy requires active commitment"
|
|
));
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_transition_started_missing_policy_id_on_replay() {
|
|
let path = temp_log_path("transition-missing-policy-id");
|
|
let mut payload = transition_started_payload();
|
|
payload
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.remove("transition_policy_id");
|
|
append_active_session_with_transition_and_policy_payload(
|
|
&path,
|
|
payload,
|
|
transition_policy_payload("commitment-test"),
|
|
);
|
|
|
|
let err = SessionController::new(&path)
|
|
.expect_err("transition policy id is required in transition payload");
|
|
|
|
assert!(error_chain_contains(&err, "transition_policy_id"));
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_transition_started_empty_policy_id_on_replay() {
|
|
let path = temp_log_path("transition-empty-policy-id");
|
|
let mut payload = transition_started_payload();
|
|
payload["transition_policy_id"] = json!("");
|
|
append_active_session_with_transition_payload(&path, payload);
|
|
|
|
let err =
|
|
SessionController::new(&path).expect_err("empty transition policy id must be rejected");
|
|
|
|
assert!(error_chain_contains(&err, "transition policy id"));
|
|
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();
|
|
}
|
|
|
|
#[test]
|
|
fn timing_summary_excludes_completed_transition_interval() {
|
|
let path = temp_log_path("timing-completed-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();
|
|
|
|
std::thread::sleep(Duration::from_millis(1200));
|
|
session
|
|
.start_transition(
|
|
"Need to inspect failures",
|
|
"Return to session controller",
|
|
Duration::from_secs(300),
|
|
)
|
|
.unwrap();
|
|
std::thread::sleep(Duration::from_millis(2200));
|
|
session.return_from_transition().unwrap();
|
|
|
|
let summary = session.timing_summary().unwrap().unwrap();
|
|
let created_at = session.active_commitment().unwrap().created_at_unix_secs;
|
|
let wall_elapsed = unix_secs_now().saturating_sub(created_at);
|
|
|
|
assert_eq!(session.runtime_state(), RuntimeState::Active);
|
|
assert!(!summary.currently_in_transition);
|
|
assert!(wall_elapsed >= 3);
|
|
assert!(
|
|
summary.active_elapsed_secs <= 2,
|
|
"active elapsed should exclude completed transition time, got {}",
|
|
summary.active_elapsed_secs
|
|
);
|
|
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn timing_summary_freezes_elapsed_during_open_transition() {
|
|
let path = temp_log_path("timing-open-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();
|
|
|
|
std::thread::sleep(Duration::from_millis(1200));
|
|
session
|
|
.start_transition(
|
|
"Need to inspect failures",
|
|
"Return to session controller",
|
|
Duration::from_secs(300),
|
|
)
|
|
.unwrap();
|
|
std::thread::sleep(Duration::from_millis(2200));
|
|
|
|
let summary = session.timing_summary().unwrap().unwrap();
|
|
let created_at = session.active_commitment().unwrap().created_at_unix_secs;
|
|
let wall_elapsed = unix_secs_now().saturating_sub(created_at);
|
|
|
|
assert_eq!(session.runtime_state(), RuntimeState::Transition);
|
|
assert!(summary.currently_in_transition);
|
|
assert!(wall_elapsed >= 3);
|
|
assert!(
|
|
summary.active_elapsed_secs <= 2,
|
|
"active elapsed should freeze at transition start, got {}",
|
|
summary.active_elapsed_secs
|
|
);
|
|
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn timing_summary_stops_active_elapsed_during_review() {
|
|
let path = temp_log_path("timing-review");
|
|
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();
|
|
|
|
std::thread::sleep(Duration::from_millis(1200));
|
|
session.complete_for_review().unwrap();
|
|
std::thread::sleep(Duration::from_millis(2200));
|
|
|
|
let summary = session.timing_summary().unwrap().unwrap();
|
|
let created_at = session.active_commitment().unwrap().created_at_unix_secs;
|
|
let wall_elapsed = unix_secs_now().saturating_sub(created_at);
|
|
|
|
assert_eq!(session.runtime_state(), RuntimeState::Review);
|
|
assert!(!summary.currently_in_transition);
|
|
assert!(wall_elapsed >= 3);
|
|
assert!(
|
|
summary.active_elapsed_secs <= 2,
|
|
"active elapsed should freeze at review start, got {}",
|
|
summary.active_elapsed_secs
|
|
);
|
|
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
}
|