Skip to main content

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}