Fix violation prompt invariants
This commit is contained in:
+243
-20
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user