use turnframe_core::command::{
CommandBatch, CommandEnvelope, CommandPolicy, ConfirmationPolicy, RiskClass, origin_satisfies,
};
use turnframe_core::event::{OperationalReceipt, ReceiptEvent};
use turnframe_core::flow::{ErasedWorkflowView, PhaseOwnership, WorkflowDefinition};
use turnframe_core::ids::{CaseId, CommandId, EventId, ReceiptId, WorkflowKey};
use turnframe_core::response::AssistantTurn;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AssertionFailure {
#[error(
"command {command_id} needs a trusted origin (risk {risk:?}, confirmation {confirmation:?}) but its origin is untrusted"
)]
UntrustedOriginForHighRisk {
command_id: CommandId,
risk: RiskClass,
confirmation: ConfirmationPolicy,
},
#[error("receipt {receipt_id} ({status_code}) is not backed by any committed event")]
ReceiptWithoutEvents {
receipt_id: ReceiptId,
status_code: String,
},
#[error("receipt {receipt_id} ({status_code}) cites event {event_id}, which was not committed")]
ReceiptCitesUnknownEvent {
receipt_id: ReceiptId,
status_code: String,
event_id: EventId,
},
#[error("the view of {workflow}/{case_id} does not carry exactly one phase")]
NotASinglePhase {
workflow: WorkflowKey,
case_id: CaseId,
},
#[error("the views are of {left_workflow}/{left_case} and {right_workflow}/{right_case}")]
DifferentCases {
left_workflow: WorkflowKey,
left_case: CaseId,
right_workflow: WorkflowKey,
right_case: CaseId,
},
#[error("{workflow}/{case_id} is in phase {left} in one projection and {right} in the other")]
PhaseDiffers {
workflow: WorkflowKey,
case_id: CaseId,
left: String,
right: String,
},
#[error(
"{workflow}/{case_id} is owned by {left:?} in one projection and {right:?} in the other"
)]
PhaseOwnershipDiffers {
workflow: WorkflowKey,
case_id: CaseId,
left: PhaseOwnership,
right: PhaseOwnership,
},
#[error("the turns have {left} and {right} blocks")]
BlockCountDiffers {
left: usize,
right: usize,
},
#[error("the turns differ at block {index}")]
BlocksDiffer {
index: usize,
},
}
pub fn origin_satisfies_policy<C>(
envelope: &CommandEnvelope<C>,
policy: &CommandPolicy,
) -> Result<(), AssertionFailure> {
if origin_satisfies(&envelope.origin, policy) {
return Ok(());
}
Err(AssertionFailure::UntrustedOriginForHighRisk {
command_id: envelope.command_id,
risk: policy.risk,
confirmation: policy.confirmation,
})
}
pub fn no_high_risk_without_trusted_origin<W: WorkflowDefinition>(
definition: &W,
state: Option<&W::State>,
batch: &CommandBatch<W::Command>,
) -> Result<(), AssertionFailure> {
for envelope in &batch.envelopes {
let policy = definition.command_policy(state, &envelope.command);
origin_satisfies_policy(envelope, &policy)?;
}
Ok(())
}
pub fn receipts_backed_by_events<E>(
receipts: &[OperationalReceipt],
events: &[ReceiptEvent<E>],
) -> Result<(), AssertionFailure> {
for receipt in receipts {
if receipt.event_ids.is_empty() {
return Err(AssertionFailure::ReceiptWithoutEvents {
receipt_id: receipt.receipt_id,
status_code: receipt.status_code.clone(),
});
}
for event_id in &receipt.event_ids {
if !events.iter().any(|event| event.event_id() == *event_id) {
return Err(AssertionFailure::ReceiptCitesUnknownEvent {
receipt_id: receipt.receipt_id,
status_code: receipt.status_code.clone(),
event_id: *event_id,
});
}
}
}
Ok(())
}
pub fn single_phase(view: &ErasedWorkflowView) -> Result<(), AssertionFailure> {
if view.phase.is_null() || view.phase.is_array() {
return Err(AssertionFailure::NotASinglePhase {
workflow: view.case_ref.workflow.clone(),
case_id: view.case_ref.case_id.clone(),
});
}
Ok(())
}
pub fn same_phase_in(
left: &ErasedWorkflowView,
right: &ErasedWorkflowView,
) -> Result<(), AssertionFailure> {
if left.case_ref.key() != right.case_ref.key() {
return Err(AssertionFailure::DifferentCases {
left_workflow: left.case_ref.workflow.clone(),
left_case: left.case_ref.case_id.clone(),
right_workflow: right.case_ref.workflow.clone(),
right_case: right.case_ref.case_id.clone(),
});
}
single_phase(left)?;
single_phase(right)?;
if left.phase != right.phase {
return Err(AssertionFailure::PhaseDiffers {
workflow: left.case_ref.workflow.clone(),
case_id: left.case_ref.case_id.clone(),
left: left.phase.to_string(),
right: right.phase.to_string(),
});
}
if left.phase_ownership != right.phase_ownership {
return Err(AssertionFailure::PhaseOwnershipDiffers {
workflow: left.case_ref.workflow.clone(),
case_id: left.case_ref.case_id.clone(),
left: left.phase_ownership,
right: right.phase_ownership,
});
}
Ok(())
}
pub fn identical_blocks(
left: &AssistantTurn,
right: &AssistantTurn,
) -> Result<(), AssertionFailure> {
if left.blocks.len() != right.blocks.len() {
return Err(AssertionFailure::BlockCountDiffers {
left: left.blocks.len(),
right: right.blocks.len(),
});
}
for (index, (a, b)) in left.blocks.iter().zip(&right.blocks).enumerate() {
if a != b {
return Err(AssertionFailure::BlocksDiffer { index });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use turnframe_core::event::{OperationalReceipt, ReceiptSeverity};
use turnframe_core::ids::{BlockId, ConversationId, EventId, ReceiptId, TurnId};
use turnframe_core::locale::LocalizedText;
use turnframe_core::response::{
AssistantTurn, GeneratedTransition, ReplayToken, ResponseBlock,
};
use super::*;
fn receipt(event_ids: Vec<EventId>) -> OperationalReceipt {
OperationalReceipt {
receipt_id: ReceiptId::derive(&event_ids, "trip.rebooking_sent"),
event_ids,
severity: ReceiptSeverity::Success,
title: LocalizedText::new("Sent"),
body: LocalizedText::new("The rebooking was sent."),
status_code: "trip.rebooking_sent".to_owned(),
artifact_refs: Vec::new(),
}
}
fn turn(text: &str) -> AssistantTurn {
AssistantTurn {
turn_id: TurnId::nil(),
conversation_id: ConversationId::nil(),
blocks: vec![ResponseBlock::Transition(GeneratedTransition {
block_id: BlockId::from("t1"),
text: text.to_owned(),
facts_used: Vec::new(),
})],
replay_token: ReplayToken::from("rt"),
subjects: Vec::new(),
expectations: Vec::new(),
done: Vec::new(),
}
}
#[test]
fn a_receipt_with_no_events_is_refused() {
let failure = receipts_backed_by_events::<()>(&[receipt(Vec::new())], &[]).unwrap_err();
assert!(matches!(
failure,
AssertionFailure::ReceiptWithoutEvents { .. }
));
}
#[test]
fn a_receipt_citing_an_uncommitted_event_is_refused() {
let failure =
receipts_backed_by_events::<()>(&[receipt(vec![EventId::nil()])], &[]).unwrap_err();
assert!(matches!(
failure,
AssertionFailure::ReceiptCitesUnknownEvent { .. }
));
}
#[test]
fn a_phase_that_is_not_one_value_is_refused() {
use turnframe_core::case::CaseRef;
use turnframe_core::flow::{ErasedWorkflowView, PhaseOwnership};
use turnframe_core::ids::{CaseRevision, WorkflowVersion};
let mut view = ErasedWorkflowView {
case_ref: CaseRef::new("trip", "trip-1", CaseRevision(1)),
workflow_version: WorkflowVersion::from("1"),
phase: serde_json::json!("collecting"),
phase_ownership: PhaseOwnership::System,
obligations: Vec::new(),
blocking_interaction: None,
notices: Vec::new(),
outcome: None,
state: Vec::new(),
};
assert!(single_phase(&view).is_ok());
view.phase = serde_json::json!(["collecting", "dispatching"]);
assert!(matches!(
single_phase(&view).unwrap_err(),
AssertionFailure::NotASinglePhase { .. }
));
view.phase = serde_json::Value::Null;
assert!(single_phase(&view).is_err());
}
#[test]
fn turns_are_compared_block_by_block() {
let a = turn("Fatto.");
assert!(identical_blocks(&a, &a.clone()).is_ok());
assert_eq!(
identical_blocks(&a, &turn("Done.")).unwrap_err(),
AssertionFailure::BlocksDiffer { index: 0 }
);
let mut shorter = a.clone();
shorter.blocks.clear();
assert_eq!(
identical_blocks(&a, &shorter).unwrap_err(),
AssertionFailure::BlockCountDiffers { left: 1, right: 0 }
);
}
}