turnframe_core/event.rs
1//! Commits, committed events, receipts and external outcome states (spec §16-17).
2//!
3//! Events are the claim ledger: an operational receipt may only be emitted
4//! from committed events or an authoritative external receipt (I16).
5//!
6//! # Personal data in a payload, and how it leaves
7//!
8//! The ledger is append-only and a case's identity outlives its content, so
9//! nothing in this module removes an event or a case. That would leave personal
10//! data in a payload with no exit, which is not a position a library may hand
11//! an adopter who is legally obliged to erase it on request, so there is a
12//! third way out: the store redacts a payload **in place** and the event keeps
13//! its identity, its type, its position and its timestamps. Nothing appears,
14//! disappears or moves.
15//!
16//! What a receipt renderer is then handed is a [`ReceiptEvent`], which is a
17//! committed event *or* a [`RedactedEvent`], so a domain is told explicitly
18//! that a payload is gone and writes the copy that says so. The write half of
19//! the operation lives in the store contract
20//! (`turnframe_store::events::EventJournalWriter::redact_payload`).
21//!
22//! An event payload that carries a **reference** — a traveler id rather than a
23//! name, an attachment id rather than the text read out of it — is easier to
24//! erase, because erasing the record the reference points at empties the
25//! payload without touching the ledger at all. Design payloads that way where
26//! you can. The honest caveat is that it does not generalise: a receipt says
27//! *what changed*, and "the traveler's name is now Marta Bianchi" cannot be rendered
28//! from an id. Wherever the receipt needs the value, the value is in the
29//! payload and this path is the exit for it.
30
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33
34use crate::command::IdempotencyKey;
35use crate::hash::derive_uuid;
36use crate::ids::{
37 AttemptId, CaseRevision, CommandId, EventId, OutboxId, ReceiptId, RedactionAuthority,
38};
39use crate::locale::LocalizedText;
40
41/// Result of executing a command batch (spec §17.1).
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Commit<S, E> {
44 /// State after the commit, or `None` when the case no longer exists.
45 ///
46 /// `None` is written as a missing key, so a `State` type that itself
47 /// serializes to JSON `null` (`()`, an empty newtype, an `Option` field at
48 /// the root) reads back as "the case is gone". A workflow state must
49 /// therefore serialize to an object; the same rule holds for
50 /// [`WorkflowDefinition::Outcome`](crate::flow::WorkflowDefinition::Outcome),
51 /// whose erased form drives
52 /// [`ErasedWorkflowView::is_complete`](crate::flow::ErasedWorkflowView::is_complete).
53 /// Concrete `serde_json::Value` fields that must keep an explicit `null`
54 /// use [`FieldValue`](crate::interaction::FieldValue) instead.
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub state: Option<S>,
57 /// Revision after the commit.
58 pub new_revision: CaseRevision,
59 /// Events committed by this batch.
60 pub events: Vec<CommittedEvent<E>>,
61 /// `true` when the executor recognised the idempotency key and returned the
62 /// original outcome without repeating the effect (I14).
63 pub idempotency_replay: bool,
64}
65
66impl<S, E> Commit<S, E> {
67 /// Identifiers of all committed events.
68 #[must_use]
69 pub fn event_ids(&self) -> Vec<EventId> {
70 self.events.iter().map(|e| e.event_id).collect()
71 }
72
73 /// Lightweight references to all committed events.
74 #[must_use]
75 pub fn event_refs(&self) -> Vec<EventRef> {
76 self.events.iter().map(CommittedEvent::event_ref).collect()
77 }
78}
79
80impl<S, E: Clone> Commit<S, E> {
81 /// The commit's events in the form
82 /// [`WorkflowDefinition::receipts`](crate::flow::WorkflowDefinition::receipts)
83 /// takes.
84 ///
85 /// Every event of a fresh commit still has its payload, so this is the
86 /// whole conversion: a [`ReceiptEvent::Redacted`] only ever comes back out
87 /// of the store, never out of an execution.
88 #[must_use]
89 pub fn receipt_events(&self) -> Vec<ReceiptEvent<E>> {
90 self.events
91 .iter()
92 .cloned()
93 .map(ReceiptEvent::Committed)
94 .collect()
95 }
96}
97
98/// One committed domain event (spec §17.1).
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct CommittedEvent<E> {
101 /// Identifier in the ledger.
102 pub event_id: EventId,
103 /// Stable event type label (e.g. `"trip.extra_added"`).
104 pub event_type: String,
105 /// When it was committed.
106 pub occurred_at: DateTime<Utc>,
107 /// Domain payload.
108 pub payload: E,
109}
110
111impl<E> CommittedEvent<E> {
112 /// Reference without payload.
113 #[must_use]
114 pub fn event_ref(&self) -> EventRef {
115 EventRef {
116 event_id: self.event_id,
117 event_type: self.event_type.clone(),
118 }
119 }
120
121 /// Transforms the payload.
122 pub fn try_map_payload<F, Err>(
123 self,
124 f: impl FnOnce(E) -> Result<F, Err>,
125 ) -> Result<CommittedEvent<F>, Err> {
126 Ok(CommittedEvent {
127 event_id: self.event_id,
128 event_type: self.event_type,
129 occurred_at: self.occurred_at,
130 payload: f(self.payload)?,
131 })
132 }
133
134 /// Transforms the payload by reference, keeping identity and timestamp.
135 ///
136 /// Used at the erasure boundary, where the payload is deserialized *from*
137 /// the borrowed JSON instead of being cloned into the typed side.
138 pub fn try_map_payload_ref<F, Err>(
139 &self,
140 f: impl FnOnce(&E) -> Result<F, Err>,
141 ) -> Result<CommittedEvent<F>, Err> {
142 Ok(CommittedEvent {
143 event_id: self.event_id,
144 event_type: self.event_type.clone(),
145 occurred_at: self.occurred_at,
146 payload: f(&self.payload)?,
147 })
148 }
149}
150
151/// Reference to a committed event without its payload.
152#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
153pub struct EventRef {
154 /// Identifier in the ledger.
155 pub event_id: EventId,
156 /// Stable event type label.
157 pub event_type: String,
158}
159
160/// The record of an erasure: that a payload was removed, when, and on whose
161/// authority.
162///
163/// It deliberately does not say *what* was removed. An erasure that recorded
164/// the value it erased would erase nothing, and one that recorded nothing at
165/// all would leave an operator unable to answer why an event reads empty.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct EventRedaction {
168 /// When the payload was removed.
169 pub redacted_at: DateTime<Utc>,
170 /// Under whose authority — an erasure ticket, a retention policy key or an
171 /// operator identifier, never the data that was removed.
172 pub authority: RedactionAuthority,
173}
174
175/// A committed event whose payload was erased (see the module documentation).
176///
177/// Everything the claim guard needs survives: the identity a receipt cites, the
178/// type label, and the instant it was committed. Its position in the journal
179/// survives too, in the store; it is not part of this value because a receipt
180/// never cites a position.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct RedactedEvent {
183 /// Identifier in the ledger, unchanged by the erasure.
184 pub event_id: EventId,
185 /// Stable event type label, unchanged by the erasure.
186 pub event_type: String,
187 /// When the event was committed, unchanged by the erasure.
188 pub occurred_at: DateTime<Utc>,
189 /// When the payload was erased, and on whose authority.
190 pub redaction: EventRedaction,
191}
192
193impl RedactedEvent {
194 /// Reference to the event, which is what a receipt cites.
195 #[must_use]
196 pub fn event_ref(&self) -> EventRef {
197 EventRef {
198 event_id: self.event_id,
199 event_type: self.event_type.clone(),
200 }
201 }
202}
203
204/// One event as offered to
205/// [`WorkflowDefinition::receipts`](crate::flow::WorkflowDefinition::receipts):
206/// its payload is either still in the ledger or has been erased.
207///
208/// A domain matches on this rather than on a payload that failed to
209/// deserialize, which is the difference between a receipt that says something
210/// honest about an erased event and a turn that renders as though the data were
211/// still there.
212///
213/// # It is deliberately not `#[non_exhaustive]`
214///
215/// A payload is present or it is not; there is no third case, and the
216/// exhaustive match is the mechanism. A wildcard arm here is exactly the arm
217/// that would render an erased event as though nothing had happened, so a
218/// future variant — if one is ever justified — must break the domains that
219/// render receipts rather than fall silently into their catch-all.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum ReceiptEvent<E> {
222 /// The payload is in the ledger; the receipt may say what changed.
223 Committed(CommittedEvent<E>),
224 /// The payload was erased; the receipt may say that it happened, and that
225 /// the detail is gone.
226 Redacted(RedactedEvent),
227}
228
229impl<E> ReceiptEvent<E> {
230 /// Identifier in the ledger, whether or not the payload survived.
231 #[must_use]
232 pub fn event_id(&self) -> EventId {
233 match self {
234 Self::Committed(event) => event.event_id,
235 Self::Redacted(event) => event.event_id,
236 }
237 }
238
239 /// Stable event type label, whether or not the payload survived.
240 #[must_use]
241 pub fn event_type(&self) -> &str {
242 match self {
243 Self::Committed(event) => &event.event_type,
244 Self::Redacted(event) => &event.event_type,
245 }
246 }
247
248 /// When the event was committed, whether or not the payload survived.
249 #[must_use]
250 pub fn occurred_at(&self) -> DateTime<Utc> {
251 match self {
252 Self::Committed(event) => event.occurred_at,
253 Self::Redacted(event) => event.occurred_at,
254 }
255 }
256
257 /// Reference without payload, which is all a receipt cites.
258 #[must_use]
259 pub fn event_ref(&self) -> EventRef {
260 match self {
261 Self::Committed(event) => event.event_ref(),
262 Self::Redacted(event) => event.event_ref(),
263 }
264 }
265
266 /// The payload, or `None` when it was erased.
267 #[must_use]
268 pub fn payload(&self) -> Option<&E> {
269 match self {
270 Self::Committed(event) => Some(&event.payload),
271 Self::Redacted(_) => None,
272 }
273 }
274
275 /// The erasure record, or `None` while the payload is still there.
276 #[must_use]
277 pub fn redaction(&self) -> Option<&EventRedaction> {
278 match self {
279 Self::Committed(_) => None,
280 Self::Redacted(event) => Some(&event.redaction),
281 }
282 }
283
284 /// Returns `true` when the payload was erased.
285 #[must_use]
286 pub fn is_redacted(&self) -> bool {
287 matches!(self, Self::Redacted(_))
288 }
289
290 /// Transforms the payload by reference, keeping identity and timestamp.
291 ///
292 /// A redacted event passes through untouched: there is nothing to convert,
293 /// which is why the type-erasure boundary can hand a domain an erased event
294 /// without ever trying — and failing — to deserialize an empty payload.
295 ///
296 /// # Errors
297 ///
298 /// Whatever `f` returns for a payload that is still present.
299 pub fn try_map_payload_ref<F, Err>(
300 &self,
301 f: impl FnOnce(&E) -> Result<F, Err>,
302 ) -> Result<ReceiptEvent<F>, Err> {
303 match self {
304 Self::Committed(event) => event.try_map_payload_ref(f).map(ReceiptEvent::Committed),
305 Self::Redacted(event) => Ok(ReceiptEvent::Redacted(event.clone())),
306 }
307 }
308}
309
310impl<E> From<CommittedEvent<E>> for ReceiptEvent<E> {
311 fn from(event: CommittedEvent<E>) -> Self {
312 Self::Committed(event)
313 }
314}
315
316impl<E> From<RedactedEvent> for ReceiptEvent<E> {
317 fn from(event: RedactedEvent) -> Self {
318 Self::Redacted(event)
319 }
320}
321
322/// Severity of a receipt.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
324#[serde(rename_all = "snake_case")]
325pub enum ReceiptSeverity {
326 /// Neutral information.
327 Info,
328 /// The operation succeeded.
329 Success,
330 /// Something needs attention.
331 Warning,
332 /// The operation failed.
333 Error,
334}
335
336/// A reference to an artifact produced by a command (PDF, XML, protocol id...).
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct ArtifactRef {
339 /// Application-defined artifact identifier.
340 pub artifact_id: String,
341 /// Kind label (e.g. `"itinerary_pdf"`, `"ticket_number"`).
342 pub kind: String,
343 /// Human label.
344 pub label: LocalizedText,
345 /// Where to fetch it, if applicable.
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub uri: Option<String>,
348 /// Media type, if applicable.
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub media_type: Option<String>,
351}
352
353/// Domain-separation prefix of [`ReceiptId::derive`].
354const RECEIPT_ID_DOMAIN: &str = "turnframe.receipt_id.v1";
355
356impl ReceiptId {
357 /// Derives the identifier of a receipt from the events it cites and its
358 /// status code.
359 ///
360 /// Receipts are deterministic (spec §17.3): the same committed events
361 /// rendered again are the same receipt, so a replayed turn produces the
362 /// same identifiers and
363 /// [`claim_guard::verify`](crate::response::claim_guard::verify) can refuse
364 /// two different receipts that claim to be one.
365 ///
366 /// The event order does not change the result; the set does.
367 #[must_use]
368 pub fn derive(event_ids: &[EventId], status_code: &str) -> Self {
369 let mut ids: Vec<String> = event_ids.iter().map(EventId::to_string).collect();
370 ids.sort_unstable();
371 ids.dedup();
372 let mut parts: Vec<&str> = vec![status_code];
373 parts.extend(ids.iter().map(String::as_str));
374 Self(derive_uuid(RECEIPT_ID_DOMAIN, &parts))
375 }
376}
377
378/// A deterministic, server-rendered statement of what happened (spec §17.3).
379///
380/// Receipts are the only place where operational outcomes are stated. A
381/// receipt claiming success must reference at least one committed event.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct OperationalReceipt {
384 /// Identifier of the receipt.
385 pub receipt_id: ReceiptId,
386 /// Events that authorize the claim (I16).
387 pub event_ids: Vec<EventId>,
388 /// Severity.
389 pub severity: ReceiptSeverity,
390 /// Title copy.
391 pub title: LocalizedText,
392 /// Body copy.
393 pub body: LocalizedText,
394 /// Stable status code (e.g. `"trip.extra_added"`, `"airline.accepted"`).
395 pub status_code: String,
396 /// Artifacts produced.
397 #[serde(default)]
398 pub artifact_refs: Vec<ArtifactRef>,
399}
400
401impl OperationalReceipt {
402 /// Returns `true` when the receipt is backed by at least one event.
403 #[must_use]
404 pub fn is_event_backed(&self) -> bool {
405 !self.event_ids.is_empty()
406 }
407}
408
409/// Fine-grained state of a request another system decides (spec §17.4).
410///
411/// Never collapse these into a generic "done". New states appear whenever that
412/// system adds a step, so downstream matches need a wildcard arm.
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
414#[serde(rename_all = "snake_case")]
415#[non_exhaustive]
416pub enum ExternalStatus {
417 /// Payload prepared locally.
418 Prepared,
419 /// Payload validated locally.
420 Validated,
421 /// Waiting for the user's confirmation.
422 AwaitingConfirmation,
423 /// Transmitted to the first hop.
424 Submitted,
425 /// An intermediary acknowledged receipt.
426 ReceivedByIntermediary,
427 /// The deciding system acknowledged receipt.
428 ReceivedByAuthority,
429 /// Accepted by the deciding system.
430 Accepted,
431 /// Rejected by the deciding system or an intermediary.
432 Rejected,
433 /// Issued by the deciding system: a ticket, a permit, a booking.
434 Issued,
435 /// Could not be delivered to the recipient.
436 NotDelivered,
437 /// Delivered to the recipient.
438 Delivered,
439 /// The whole flow is complete.
440 Completed,
441}
442
443impl ExternalStatus {
444 /// Returns `true` for states after which no further external transition is
445 /// expected: [`Self::Rejected`], [`Self::NotDelivered`], [`Self::Completed`].
446 #[must_use]
447 pub fn is_final(self) -> bool {
448 matches!(self, Self::Rejected | Self::NotDelivered | Self::Completed)
449 }
450
451 /// Returns `true` once the payload has left the system, i.e. an external
452 /// effect may exist.
453 #[must_use]
454 pub fn is_transmitted(self) -> bool {
455 self >= Self::Submitted
456 }
457}
458
459/// An external effect was attempted and its result is unknown (I15).
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
461#[error("external outcome unknown for attempt {attempt_id} ({reason})")]
462pub struct UnknownOutcome {
463 /// Identifier of the attempt (outbox or dispatcher scoped).
464 pub attempt_id: AttemptId,
465 /// Remote identifier to reconcile with, if the remote returned one.
466 pub remote_ref: Option<String>,
467 /// Stable reason code (e.g. `"timeout_after_send"`), never free text.
468 pub reason: String,
469}
470
471/// Status of an outbox row (spec §16.4).
472///
473/// Dispatch strategies grow, so downstream matches need a wildcard arm.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476#[non_exhaustive]
477pub enum OutboxStatus {
478 /// Waiting for dispatch.
479 Pending,
480 /// A dispatcher is calling the external system.
481 Dispatching,
482 /// The call was made and the outcome is unknown; reconcile, do not retry blindly.
483 OutcomeUnknown,
484 /// The external system confirmed.
485 Completed,
486 /// The external system definitively refused.
487 Failed,
488}
489
490impl OutboxStatus {
491 /// Returns `true` when no further dispatch will happen.
492 #[must_use]
493 pub fn is_terminal(self) -> bool {
494 matches!(self, Self::Completed | Self::Failed)
495 }
496
497 /// Legal transitions of the outbox state machine.
498 #[must_use]
499 pub fn can_transition(from: Self, to: Self) -> bool {
500 matches!(
501 (from, to),
502 (Self::Pending, Self::Dispatching)
503 | (Self::Dispatching, Self::Pending)
504 | (Self::Dispatching, Self::OutcomeUnknown)
505 | (Self::Dispatching, Self::Completed)
506 | (Self::Dispatching, Self::Failed)
507 | (Self::OutcomeUnknown, Self::Completed)
508 | (Self::OutcomeUnknown, Self::Failed)
509 | (Self::OutcomeUnknown, Self::Pending)
510 )
511 }
512}
513
514/// One external side effect awaiting dispatch (spec §16.4).
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct OutboxEntry {
517 /// Identifier of the row.
518 pub outbox_id: OutboxId,
519 /// Command that produced it.
520 pub command_id: CommandId,
521 /// Destination label (e.g. `"airline"`).
522 pub destination: String,
523 /// Payload for the dispatcher.
524 pub payload: serde_json::Value,
525 /// Idempotency key forwarded to the external system.
526 pub idempotency_key: IdempotencyKey,
527 /// Current status.
528 pub status: OutboxStatus,
529 /// Attempts made so far.
530 pub attempt_count: u32,
531 /// Earliest next attempt.
532 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub next_attempt_at: Option<DateTime<Utc>>,
534 /// Creation time.
535 pub created_at: DateTime<Utc>,
536 /// Completion time.
537 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub completed_at: Option<DateTime<Utc>>,
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn external_status_finality() {
547 assert!(ExternalStatus::Completed.is_final());
548 assert!(ExternalStatus::Rejected.is_final());
549 assert!(ExternalStatus::NotDelivered.is_final());
550 assert!(!ExternalStatus::Accepted.is_final());
551 assert!(ExternalStatus::Submitted.is_transmitted());
552 assert!(!ExternalStatus::Validated.is_transmitted());
553 }
554
555 #[test]
556 fn receipt_ids_are_derived_from_events_and_status() {
557 let a = EventId::nil();
558 let b = EventId::from(uuid::Uuid::from_u128(7));
559 let id = ReceiptId::derive(&[a, b], "trip.rebooking_sent");
560 assert_eq!(id, ReceiptId::derive(&[a, b], "trip.rebooking_sent"));
561 assert_eq!(
562 id,
563 ReceiptId::derive(&[b, a], "trip.rebooking_sent"),
564 "order free"
565 );
566 assert_ne!(id, ReceiptId::derive(&[a], "trip.rebooking_sent"));
567 assert_ne!(id, ReceiptId::derive(&[a, b], "trip.refused"));
568 }
569
570 #[test]
571 fn a_redacted_event_keeps_everything_a_claim_rests_on() {
572 let committed = CommittedEvent {
573 event_id: EventId::from(uuid::Uuid::from_u128(11)),
574 event_type: "trip.extra_added".to_owned(),
575 occurred_at: DateTime::<Utc>::UNIX_EPOCH,
576 payload: serde_json::json!({ "description": "a name that must go" }),
577 };
578 let redacted = RedactedEvent {
579 event_id: committed.event_id,
580 event_type: committed.event_type.clone(),
581 occurred_at: committed.occurred_at,
582 redaction: EventRedaction {
583 redacted_at: DateTime::<Utc>::UNIX_EPOCH,
584 authority: RedactionAuthority::from("erasure-request-8842"),
585 },
586 };
587
588 let present = ReceiptEvent::from(committed.clone());
589 let gone = ReceiptEvent::<serde_json::Value>::from(redacted);
590
591 // Identity, type and instant are what the claim guard and a receipt
592 // need, and they are the same on both sides.
593 assert_eq!(present.event_id(), gone.event_id());
594 assert_eq!(present.event_type(), gone.event_type());
595 assert_eq!(present.occurred_at(), gone.occurred_at());
596 assert_eq!(present.event_ref(), gone.event_ref());
597
598 // The payload is the only thing that differs, and it differs visibly.
599 assert!(!present.is_redacted());
600 assert!(gone.is_redacted());
601 assert!(present.payload().is_some());
602 assert!(gone.payload().is_none());
603 assert!(present.redaction().is_none());
604 assert_eq!(
605 gone.redaction().map(|r| r.authority.as_str()),
606 Some("erasure-request-8842")
607 );
608
609 // An erased payload has nothing to convert, so the type-erasure
610 // boundary cannot turn an erasure into a deserialization failure.
611 let mapped = gone.try_map_payload_ref(|_: &serde_json::Value| Err::<(), &str>("never run"));
612 assert!(mapped.is_ok(), "a redacted event maps without calling f");
613 assert!(mapped.unwrap_or_else(|_| unreachable!()).is_redacted());
614 assert!(
615 present
616 .try_map_payload_ref(|_: &serde_json::Value| Err::<(), &str>("boom"))
617 .is_err(),
618 "a present payload still goes through f"
619 );
620 }
621
622 #[test]
623 fn a_commit_offers_its_events_for_receipt_rendering() {
624 let commit: Commit<(), u32> = Commit {
625 state: None,
626 new_revision: CaseRevision(1),
627 events: vec![CommittedEvent {
628 event_id: EventId::nil(),
629 event_type: "t".to_owned(),
630 occurred_at: DateTime::<Utc>::UNIX_EPOCH,
631 payload: 7,
632 }],
633 idempotency_replay: false,
634 };
635 let events = commit.receipt_events();
636 assert_eq!(events.len(), 1);
637 assert_eq!(events[0].payload(), Some(&7));
638 assert!(
639 !events[0].is_redacted(),
640 "a fresh commit never carries an erased payload"
641 );
642 }
643
644 #[test]
645 fn outbox_transitions() {
646 assert!(OutboxStatus::can_transition(
647 OutboxStatus::Pending,
648 OutboxStatus::Dispatching
649 ));
650 assert!(OutboxStatus::can_transition(
651 OutboxStatus::Dispatching,
652 OutboxStatus::OutcomeUnknown
653 ));
654 assert!(!OutboxStatus::can_transition(
655 OutboxStatus::Completed,
656 OutboxStatus::Pending
657 ));
658 assert!(!OutboxStatus::can_transition(
659 OutboxStatus::Pending,
660 OutboxStatus::Completed
661 ));
662 }
663}