1use turnframe_core::command::{
19 CommandBatch, CommandEnvelope, CommandPolicy, ConfirmationPolicy, RiskClass, origin_satisfies,
20};
21use turnframe_core::event::{OperationalReceipt, ReceiptEvent};
22use turnframe_core::flow::{ErasedWorkflowView, PhaseOwnership, WorkflowDefinition};
23use turnframe_core::ids::{CaseId, CommandId, EventId, ReceiptId, WorkflowKey};
24use turnframe_core::response::AssistantTurn;
25
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28#[non_exhaustive]
29pub enum AssertionFailure {
30 #[error(
32 "command {command_id} needs a trusted origin (risk {risk:?}, confirmation {confirmation:?}) but its origin is untrusted"
33 )]
34 UntrustedOriginForHighRisk {
35 command_id: CommandId,
37 risk: RiskClass,
39 confirmation: ConfirmationPolicy,
41 },
42 #[error("receipt {receipt_id} ({status_code}) is not backed by any committed event")]
44 ReceiptWithoutEvents {
45 receipt_id: ReceiptId,
47 status_code: String,
49 },
50 #[error("receipt {receipt_id} ({status_code}) cites event {event_id}, which was not committed")]
52 ReceiptCitesUnknownEvent {
53 receipt_id: ReceiptId,
55 status_code: String,
57 event_id: EventId,
59 },
60 #[error("the view of {workflow}/{case_id} does not carry exactly one phase")]
62 NotASinglePhase {
63 workflow: WorkflowKey,
65 case_id: CaseId,
67 },
68 #[error("the views are of {left_workflow}/{left_case} and {right_workflow}/{right_case}")]
70 DifferentCases {
71 left_workflow: WorkflowKey,
73 left_case: CaseId,
75 right_workflow: WorkflowKey,
77 right_case: CaseId,
79 },
80 #[error("{workflow}/{case_id} is in phase {left} in one projection and {right} in the other")]
82 PhaseDiffers {
83 workflow: WorkflowKey,
85 case_id: CaseId,
87 left: String,
89 right: String,
91 },
92 #[error(
94 "{workflow}/{case_id} is owned by {left:?} in one projection and {right:?} in the other"
95 )]
96 PhaseOwnershipDiffers {
97 workflow: WorkflowKey,
99 case_id: CaseId,
101 left: PhaseOwnership,
103 right: PhaseOwnership,
105 },
106 #[error("the turns have {left} and {right} blocks")]
108 BlockCountDiffers {
109 left: usize,
111 right: usize,
113 },
114 #[error("the turns differ at block {index}")]
116 BlocksDiffer {
117 index: usize,
119 },
120}
121
122pub fn origin_satisfies_policy<C>(
124 envelope: &CommandEnvelope<C>,
125 policy: &CommandPolicy,
126) -> Result<(), AssertionFailure> {
127 if origin_satisfies(&envelope.origin, policy) {
128 return Ok(());
129 }
130 Err(AssertionFailure::UntrustedOriginForHighRisk {
131 command_id: envelope.command_id,
132 risk: policy.risk,
133 confirmation: policy.confirmation,
134 })
135}
136
137pub fn no_high_risk_without_trusted_origin<W: WorkflowDefinition>(
144 definition: &W,
145 state: Option<&W::State>,
146 batch: &CommandBatch<W::Command>,
147) -> Result<(), AssertionFailure> {
148 for envelope in &batch.envelopes {
149 let policy = definition.command_policy(state, &envelope.command);
150 origin_satisfies_policy(envelope, &policy)?;
151 }
152 Ok(())
153}
154
155pub fn receipts_backed_by_events<E>(
169 receipts: &[OperationalReceipt],
170 events: &[ReceiptEvent<E>],
171) -> Result<(), AssertionFailure> {
172 for receipt in receipts {
173 if receipt.event_ids.is_empty() {
174 return Err(AssertionFailure::ReceiptWithoutEvents {
175 receipt_id: receipt.receipt_id,
176 status_code: receipt.status_code.clone(),
177 });
178 }
179 for event_id in &receipt.event_ids {
180 if !events.iter().any(|event| event.event_id() == *event_id) {
181 return Err(AssertionFailure::ReceiptCitesUnknownEvent {
182 receipt_id: receipt.receipt_id,
183 status_code: receipt.status_code.clone(),
184 event_id: *event_id,
185 });
186 }
187 }
188 }
189 Ok(())
190}
191
192pub fn single_phase(view: &ErasedWorkflowView) -> Result<(), AssertionFailure> {
199 if view.phase.is_null() || view.phase.is_array() {
200 return Err(AssertionFailure::NotASinglePhase {
201 workflow: view.case_ref.workflow.clone(),
202 case_id: view.case_ref.case_id.clone(),
203 });
204 }
205 Ok(())
206}
207
208pub fn same_phase_in(
221 left: &ErasedWorkflowView,
222 right: &ErasedWorkflowView,
223) -> Result<(), AssertionFailure> {
224 if left.case_ref.key() != right.case_ref.key() {
225 return Err(AssertionFailure::DifferentCases {
226 left_workflow: left.case_ref.workflow.clone(),
227 left_case: left.case_ref.case_id.clone(),
228 right_workflow: right.case_ref.workflow.clone(),
229 right_case: right.case_ref.case_id.clone(),
230 });
231 }
232 single_phase(left)?;
233 single_phase(right)?;
234 if left.phase != right.phase {
235 return Err(AssertionFailure::PhaseDiffers {
236 workflow: left.case_ref.workflow.clone(),
237 case_id: left.case_ref.case_id.clone(),
238 left: left.phase.to_string(),
239 right: right.phase.to_string(),
240 });
241 }
242 if left.phase_ownership != right.phase_ownership {
243 return Err(AssertionFailure::PhaseOwnershipDiffers {
244 workflow: left.case_ref.workflow.clone(),
245 case_id: left.case_ref.case_id.clone(),
246 left: left.phase_ownership,
247 right: right.phase_ownership,
248 });
249 }
250 Ok(())
251}
252
253pub fn identical_blocks(
259 left: &AssistantTurn,
260 right: &AssistantTurn,
261) -> Result<(), AssertionFailure> {
262 if left.blocks.len() != right.blocks.len() {
263 return Err(AssertionFailure::BlockCountDiffers {
264 left: left.blocks.len(),
265 right: right.blocks.len(),
266 });
267 }
268 for (index, (a, b)) in left.blocks.iter().zip(&right.blocks).enumerate() {
269 if a != b {
270 return Err(AssertionFailure::BlocksDiffer { index });
271 }
272 }
273 Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278 use turnframe_core::event::{OperationalReceipt, ReceiptSeverity};
279 use turnframe_core::ids::{BlockId, ConversationId, EventId, ReceiptId, TurnId};
280 use turnframe_core::locale::LocalizedText;
281 use turnframe_core::response::{
282 AssistantTurn, GeneratedTransition, ReplayToken, ResponseBlock,
283 };
284
285 use super::*;
286
287 fn receipt(event_ids: Vec<EventId>) -> OperationalReceipt {
288 OperationalReceipt {
289 receipt_id: ReceiptId::derive(&event_ids, "trip.rebooking_sent"),
290 event_ids,
291 severity: ReceiptSeverity::Success,
292 title: LocalizedText::new("Sent"),
293 body: LocalizedText::new("The rebooking was sent."),
294 status_code: "trip.rebooking_sent".to_owned(),
295 artifact_refs: Vec::new(),
296 }
297 }
298
299 fn turn(text: &str) -> AssistantTurn {
300 AssistantTurn {
301 turn_id: TurnId::nil(),
302 conversation_id: ConversationId::nil(),
303 blocks: vec![ResponseBlock::Transition(GeneratedTransition {
304 block_id: BlockId::from("t1"),
305 text: text.to_owned(),
306 facts_used: Vec::new(),
307 })],
308 replay_token: ReplayToken::from("rt"),
309 subjects: Vec::new(),
310 expectations: Vec::new(),
311 done: Vec::new(),
312 }
313 }
314
315 #[test]
316 fn a_receipt_with_no_events_is_refused() {
317 let failure = receipts_backed_by_events::<()>(&[receipt(Vec::new())], &[]).unwrap_err();
318 assert!(matches!(
319 failure,
320 AssertionFailure::ReceiptWithoutEvents { .. }
321 ));
322 }
323
324 #[test]
325 fn a_receipt_citing_an_uncommitted_event_is_refused() {
326 let failure =
327 receipts_backed_by_events::<()>(&[receipt(vec![EventId::nil()])], &[]).unwrap_err();
328 assert!(matches!(
329 failure,
330 AssertionFailure::ReceiptCitesUnknownEvent { .. }
331 ));
332 }
333
334 #[test]
335 fn a_phase_that_is_not_one_value_is_refused() {
336 use turnframe_core::case::CaseRef;
337 use turnframe_core::flow::{ErasedWorkflowView, PhaseOwnership};
338 use turnframe_core::ids::{CaseRevision, WorkflowVersion};
339
340 let mut view = ErasedWorkflowView {
341 case_ref: CaseRef::new("trip", "trip-1", CaseRevision(1)),
342 workflow_version: WorkflowVersion::from("1"),
343 phase: serde_json::json!("collecting"),
344 phase_ownership: PhaseOwnership::System,
345 obligations: Vec::new(),
346 blocking_interaction: None,
347 notices: Vec::new(),
348 outcome: None,
349 state: Vec::new(),
350 };
351 assert!(single_phase(&view).is_ok());
352 view.phase = serde_json::json!(["collecting", "dispatching"]);
353 assert!(matches!(
354 single_phase(&view).unwrap_err(),
355 AssertionFailure::NotASinglePhase { .. }
356 ));
357 view.phase = serde_json::Value::Null;
358 assert!(single_phase(&view).is_err());
359 }
360
361 #[test]
362 fn turns_are_compared_block_by_block() {
363 let a = turn("Fatto.");
364 assert!(identical_blocks(&a, &a.clone()).is_ok());
365 assert_eq!(
366 identical_blocks(&a, &turn("Done.")).unwrap_err(),
367 AssertionFailure::BlocksDiffer { index: 0 }
368 );
369 let mut shorter = a.clone();
370 shorter.blocks.clear();
371 assert_eq!(
372 identical_blocks(&a, &shorter).unwrap_err(),
373 AssertionFailure::BlockCountDiffers { left: 1, right: 0 }
374 );
375 }
376}