Add tier zero violation friction
This commit is contained in:
+135
@@ -8,6 +8,7 @@ use crate::state_machine::{
|
||||
};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -68,6 +69,14 @@ struct TransitionStartedPayload {
|
||||
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)]
|
||||
struct PendingTransitionPolicy {
|
||||
commitment_id: String,
|
||||
@@ -523,6 +532,28 @@ impl SessionController {
|
||||
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)?;
|
||||
|
||||
self.event_log
|
||||
.append(
|
||||
EventType::ViolationObserved,
|
||||
self.runtime_state,
|
||||
self.active_commitment
|
||||
.as_ref()
|
||||
.map(|commitment| commitment.id.clone()),
|
||||
serde_json::to_value(payload).context("serialize violation observed payload")?,
|
||||
)
|
||||
.context("log violation observation")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hydrate_session(
|
||||
@@ -832,6 +863,38 @@ fn hydrate_session(
|
||||
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 record.runtime_state != runtime_state {
|
||||
return Err(anyhow!(
|
||||
"violation 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!(
|
||||
"violation references unknown commitment '{}' at sequence {}",
|
||||
commitment_id,
|
||||
record.sequence
|
||||
)
|
||||
})?;
|
||||
if active.id != commitment_id {
|
||||
return Err(anyhow!(
|
||||
"violation commitment mismatch at sequence {}",
|
||||
record.sequence
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ref unsupported => {
|
||||
return Err(anyhow!(
|
||||
"unsupported session event {:?} at sequence {}",
|
||||
@@ -993,6 +1056,13 @@ fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_violation_observed_payload(payload: &ViolationObservedPayload) -> Result<()> {
|
||||
if payload.reason.trim().is_empty() {
|
||||
return Err(anyhow!("violation reason is required"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
@@ -1176,6 +1246,71 @@ mod tests {
|
||||
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 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 activation_batch_emits_adjacent_stable_payloads() {
|
||||
let path = temp_log_path("activation-batch");
|
||||
|
||||
Reference in New Issue
Block a user