Surface degraded evidence
This commit is contained in:
+227
-2
@@ -1,6 +1,6 @@
|
||||
use crate::domain::{
|
||||
unix_secs_now, AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel,
|
||||
PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION,
|
||||
EvidenceHealth, PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION,
|
||||
};
|
||||
use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord};
|
||||
use crate::state_machine::{
|
||||
@@ -77,6 +77,13 @@ struct ViolationObservedPayload {
|
||||
evidence: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EvidenceObservedPayload {
|
||||
schema_version: u16,
|
||||
health: EvidenceHealth,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct PendingTransitionPolicy {
|
||||
commitment_id: String,
|
||||
@@ -561,6 +568,30 @@ impl SessionController {
|
||||
|
||||
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(
|
||||
@@ -914,6 +945,37 @@ fn hydrate_session(
|
||||
));
|
||||
}
|
||||
}
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ref unsupported => {
|
||||
return Err(anyhow!(
|
||||
"unsupported session event {:?} at sequence {}",
|
||||
@@ -1082,6 +1144,19 @@ fn validate_violation_observed_payload(payload: &ViolationObservedPayload) -> Re
|
||||
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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
@@ -1090,7 +1165,7 @@ mod tests {
|
||||
};
|
||||
use crate::domain::{
|
||||
unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel,
|
||||
RuntimeState, POLICY_SCHEMA_VERSION,
|
||||
EvidenceHealth, RuntimeState, POLICY_SCHEMA_VERSION,
|
||||
};
|
||||
use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord};
|
||||
use crate::session::SessionController;
|
||||
@@ -1317,6 +1392,156 @@ mod tests {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user