Fix violation prompt invariants

This commit is contained in:
2026-05-26 09:57:01 -04:00
parent ed00c7c24f
commit fd166d1ebb
2 changed files with 351 additions and 34 deletions
+243 -20
View File
@@ -540,14 +540,21 @@ impl SessionController {
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,
self.runtime_state,
self.active_commitment
.as_ref()
.map(|commitment| commitment.id.clone()),
RuntimeState::Active,
Some(commitment_id),
serde_json::to_value(payload).context("serialize violation observed payload")?,
)
.context("log violation observation")?;
@@ -873,26 +880,38 @@ fn hydrate_session(
record.sequence
)
})?;
if record.runtime_state != runtime_state {
if runtime_state != RuntimeState::Active
|| record.runtime_state != RuntimeState::Active
{
return Err(anyhow!(
"violation runtime mismatch at sequence {}",
"violation requires active runtime 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
));
}
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
));
}
}
ref unsupported => {
@@ -1311,6 +1330,210 @@ mod tests {
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");