Skip to main content

exo_core/
bcts.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Bailment-Conditioned Transaction Set (BCTS) state machine.
18//!
19//! The BCTS is the constitutional transaction lifecycle within EXOCHAIN.
20//! Every transaction moves through a strict state machine with
21//! cryptographic receipt chaining, actor attribution, and HLC-ordered
22//! transitions.
23
24use serde::{Deserialize, Serialize};
25
26use crate::{
27    error::{ExoError, Result},
28    hash::hash_structured,
29    hlc::HybridClock,
30    types::{CorrelationId, Did, Hash256, Timestamp},
31};
32
33// ---------------------------------------------------------------------------
34// BctsState
35// ---------------------------------------------------------------------------
36
37/// The lifecycle states of a BCTS transaction.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39pub enum BctsState {
40    Draft,
41    Submitted,
42    IdentityResolved,
43    ConsentValidated,
44    Deliberated,
45    Verified,
46    Governed,
47    Approved,
48    Executed,
49    Recorded,
50    Closed,
51    Denied,
52    Escalated,
53    Remediated,
54}
55
56impl BctsState {
57    /// Stable label for persistence, API, and receipt text.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Draft => "Draft",
62            Self::Submitted => "Submitted",
63            Self::IdentityResolved => "IdentityResolved",
64            Self::ConsentValidated => "ConsentValidated",
65            Self::Deliberated => "Deliberated",
66            Self::Verified => "Verified",
67            Self::Governed => "Governed",
68            Self::Approved => "Approved",
69            Self::Executed => "Executed",
70            Self::Recorded => "Recorded",
71            Self::Closed => "Closed",
72            Self::Denied => "Denied",
73            Self::Escalated => "Escalated",
74            Self::Remediated => "Remediated",
75        }
76    }
77
78    /// Return the set of states that are valid successors of `self`.
79    #[must_use]
80    pub fn valid_transitions(self) -> &'static [BctsState] {
81        use BctsState::*;
82        match self {
83            Draft => &[Submitted],
84            Submitted => &[IdentityResolved, Denied],
85            IdentityResolved => &[ConsentValidated, Denied],
86            ConsentValidated => &[Deliberated, Denied],
87            Deliberated => &[Verified, Denied, Escalated],
88            Verified => &[Governed, Denied, Escalated],
89            Governed => &[Approved, Denied, Escalated],
90            Approved => &[Executed, Denied],
91            Executed => &[Recorded, Escalated],
92            Recorded => &[Closed, Escalated],
93            Closed => &[],
94            Denied => &[Remediated],
95            Escalated => &[Deliberated, Denied, Remediated],
96            Remediated => &[Submitted],
97        }
98    }
99
100    /// Check whether transitioning to `target` is allowed.
101    #[must_use]
102    pub fn can_transition_to(self, target: BctsState) -> bool {
103        self.valid_transitions().contains(&target)
104    }
105}
106
107impl core::fmt::Display for BctsState {
108    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
109        f.write_str(self.as_str())
110    }
111}
112
113// ---------------------------------------------------------------------------
114// BctsTransition
115// ---------------------------------------------------------------------------
116
117/// Record of a single state transition in a BCTS transaction.
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119pub struct BctsTransition {
120    pub from_state: BctsState,
121    pub to_state: BctsState,
122    pub timestamp: Timestamp,
123    pub receipt_hash: Hash256,
124    pub actor_did: Did,
125}
126
127/// Deterministic intent supplied to constitutional adjudicators before a BCTS
128/// state mutation is applied.
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130pub struct BctsTransitionRequest {
131    pub correlation_id: CorrelationId,
132    pub from_state: BctsState,
133    pub to_state: BctsState,
134    pub actor_did: Did,
135    pub prior_receipt_hash: Hash256,
136}
137
138/// Constitutional gate invoked by BCTS before applying a valid state transition.
139pub trait BctsTransitionAdjudicator {
140    /// Adjudicate the transition intent.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error when constitutional invariants deny or escalate the
145    /// transition request.
146    fn adjudicate_transition(&self, request: &BctsTransitionRequest) -> Result<()>;
147}
148
149impl<F> BctsTransitionAdjudicator for F
150where
151    F: Fn(&BctsTransitionRequest) -> Result<()>,
152{
153    fn adjudicate_transition(&self, request: &BctsTransitionRequest) -> Result<()> {
154        self(request)
155    }
156}
157
158// ---------------------------------------------------------------------------
159// BailmentTransaction trait
160// ---------------------------------------------------------------------------
161
162/// The contract every BCTS transaction implementation must satisfy.
163pub trait BailmentTransaction {
164    /// Current state of the transaction.
165    fn state(&self) -> BctsState;
166
167    /// Attempt a state transition.
168    ///
169    /// # Errors
170    ///
171    /// Returns `ExoError::InvalidTransition` if the transition violates the
172    /// state machine.
173    fn transition(
174        &mut self,
175        to: BctsState,
176        actor: &Did,
177        clock: &mut HybridClock,
178        adjudicator: &dyn BctsTransitionAdjudicator,
179    ) -> Result<BctsTransition>;
180
181    /// The chain of receipt hashes for every transition so far.
182    fn receipt_chain(&self) -> &[Hash256];
183
184    /// The correlation ID for end-to-end tracking.
185    fn correlation_id(&self) -> &CorrelationId;
186}
187
188// ---------------------------------------------------------------------------
189// Transaction (concrete implementation)
190// ---------------------------------------------------------------------------
191
192/// A concrete BCTS transaction with receipt-chain integrity.
193#[derive(Clone, Debug, Serialize, Deserialize)]
194pub struct Transaction {
195    correlation_id: CorrelationId,
196    current_state: BctsState,
197    receipt_chain: Vec<Hash256>,
198    transitions: Vec<BctsTransition>,
199}
200
201impl Transaction {
202    /// Create a new transaction in the `Draft` state.
203    #[must_use]
204    pub fn new(correlation_id: CorrelationId) -> Self {
205        Self {
206            correlation_id,
207            current_state: BctsState::Draft,
208            receipt_chain: Vec::new(),
209            transitions: Vec::new(),
210        }
211    }
212
213    /// Return all recorded transitions.
214    #[must_use]
215    pub fn transitions(&self) -> &[BctsTransition] {
216        &self.transitions
217    }
218
219    /// Compute a receipt hash that chains to the previous receipt.
220    fn compute_receipt(
221        &self,
222        from: BctsState,
223        to: BctsState,
224        timestamp: &Timestamp,
225        actor: &Did,
226    ) -> Result<Hash256> {
227        // Build a canonical structure to hash
228        #[derive(Serialize)]
229        struct ReceiptInput<'a> {
230            from: BctsState,
231            to: BctsState,
232            timestamp: &'a Timestamp,
233            actor: &'a str,
234            prev_hash: Hash256,
235        }
236        let prev = self.receipt_chain.last().copied().unwrap_or(Hash256::ZERO);
237        let input = ReceiptInput {
238            from,
239            to,
240            timestamp,
241            actor: actor.as_str(),
242            prev_hash: prev,
243        };
244        hash_structured(&input)
245    }
246
247    /// Verify the integrity of the receipt chain.
248    ///
249    /// Re-computes each receipt from the corresponding transition and checks
250    /// it matches the stored receipt.
251    pub fn verify_receipt_chain(&self) -> Result<()> {
252        let mut prev = Hash256::ZERO;
253        for (i, transition) in self.transitions.iter().enumerate() {
254            #[derive(Serialize)]
255            struct ReceiptInput<'a> {
256                from: BctsState,
257                to: BctsState,
258                timestamp: &'a Timestamp,
259                actor: &'a str,
260                prev_hash: Hash256,
261            }
262            let input = ReceiptInput {
263                from: transition.from_state,
264                to: transition.to_state,
265                timestamp: &transition.timestamp,
266                actor: transition.actor_did.as_str(),
267                prev_hash: prev,
268            };
269            let computed = hash_structured(&input)?;
270            if computed != transition.receipt_hash {
271                return Err(ExoError::ReceiptChainBroken { index: i });
272            }
273            if i < self.receipt_chain.len() && self.receipt_chain[i] != computed {
274                return Err(ExoError::ReceiptChainBroken { index: i });
275            }
276            prev = computed;
277        }
278        Ok(())
279    }
280}
281
282impl BailmentTransaction for Transaction {
283    fn state(&self) -> BctsState {
284        self.current_state
285    }
286
287    fn transition(
288        &mut self,
289        to: BctsState,
290        actor: &Did,
291        clock: &mut HybridClock,
292        adjudicator: &dyn BctsTransitionAdjudicator,
293    ) -> Result<BctsTransition> {
294        let from = self.current_state;
295        if !from.can_transition_to(to) {
296            return Err(ExoError::InvalidTransition {
297                from: from.to_string(),
298                to: to.to_string(),
299            });
300        }
301
302        let prior_receipt_hash = self.receipt_chain.last().copied().unwrap_or(Hash256::ZERO);
303        adjudicator.adjudicate_transition(&BctsTransitionRequest {
304            correlation_id: self.correlation_id,
305            from_state: from,
306            to_state: to,
307            actor_did: actor.clone(),
308            prior_receipt_hash,
309        })?;
310
311        let timestamp = clock.now()?;
312        let receipt_hash = self.compute_receipt(from, to, &timestamp, actor)?;
313
314        let transition = BctsTransition {
315            from_state: from,
316            to_state: to,
317            timestamp,
318            receipt_hash,
319            actor_did: actor.clone(),
320        };
321
322        self.current_state = to;
323        self.receipt_chain.push(receipt_hash);
324        self.transitions.push(transition.clone());
325
326        Ok(transition)
327    }
328
329    fn receipt_chain(&self) -> &[Hash256] {
330        &self.receipt_chain
331    }
332
333    fn correlation_id(&self) -> &CorrelationId {
334        &self.correlation_id
335    }
336}
337
338// ===========================================================================
339// Tests
340// ===========================================================================
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    macro_rules! correlation_id {
347        () => {
348            CorrelationId::from_uuid(uuid::Uuid::from_u128(u128::from(line!())))
349        };
350    }
351
352    fn test_clock() -> HybridClock {
353        let counter = std::sync::atomic::AtomicU64::new(1000);
354        HybridClock::with_wall_clock(move || {
355            counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
356        })
357    }
358
359    fn test_did() -> Did {
360        Did::new("did:exo:test-actor").expect("valid")
361    }
362
363    struct AllowAllAdjudicator;
364
365    impl BctsTransitionAdjudicator for AllowAllAdjudicator {
366        fn adjudicate_transition(&self, _request: &BctsTransitionRequest) -> Result<()> {
367            Ok(())
368        }
369    }
370
371    // -- BctsState ---------------------------------------------------------
372
373    #[test]
374    fn state_display() {
375        assert_eq!(BctsState::Draft.to_string(), "Draft");
376        assert_eq!(BctsState::Closed.to_string(), "Closed");
377    }
378
379    #[test]
380    fn draft_can_only_go_to_submitted() {
381        assert!(BctsState::Draft.can_transition_to(BctsState::Submitted));
382        assert!(!BctsState::Draft.can_transition_to(BctsState::Closed));
383        assert!(!BctsState::Draft.can_transition_to(BctsState::Draft));
384    }
385
386    #[test]
387    fn submitted_transitions() {
388        assert!(BctsState::Submitted.can_transition_to(BctsState::IdentityResolved));
389        assert!(BctsState::Submitted.can_transition_to(BctsState::Denied));
390        assert!(!BctsState::Submitted.can_transition_to(BctsState::Closed));
391    }
392
393    #[test]
394    fn identity_resolved_transitions() {
395        assert!(BctsState::IdentityResolved.can_transition_to(BctsState::ConsentValidated));
396        assert!(BctsState::IdentityResolved.can_transition_to(BctsState::Denied));
397        assert!(!BctsState::IdentityResolved.can_transition_to(BctsState::Submitted));
398    }
399
400    #[test]
401    fn consent_validated_transitions() {
402        assert!(BctsState::ConsentValidated.can_transition_to(BctsState::Deliberated));
403        assert!(BctsState::ConsentValidated.can_transition_to(BctsState::Denied));
404        assert!(!BctsState::ConsentValidated.can_transition_to(BctsState::Executed));
405    }
406
407    #[test]
408    fn deliberated_transitions() {
409        assert!(BctsState::Deliberated.can_transition_to(BctsState::Verified));
410        assert!(BctsState::Deliberated.can_transition_to(BctsState::Denied));
411        assert!(BctsState::Deliberated.can_transition_to(BctsState::Escalated));
412        assert!(!BctsState::Deliberated.can_transition_to(BctsState::Closed));
413    }
414
415    #[test]
416    fn verified_transitions() {
417        assert!(BctsState::Verified.can_transition_to(BctsState::Governed));
418        assert!(BctsState::Verified.can_transition_to(BctsState::Denied));
419        assert!(BctsState::Verified.can_transition_to(BctsState::Escalated));
420    }
421
422    #[test]
423    fn governed_transitions() {
424        assert!(BctsState::Governed.can_transition_to(BctsState::Approved));
425        assert!(BctsState::Governed.can_transition_to(BctsState::Denied));
426        assert!(BctsState::Governed.can_transition_to(BctsState::Escalated));
427    }
428
429    #[test]
430    fn approved_transitions() {
431        assert!(BctsState::Approved.can_transition_to(BctsState::Executed));
432        assert!(BctsState::Approved.can_transition_to(BctsState::Denied));
433        assert!(!BctsState::Approved.can_transition_to(BctsState::Escalated));
434    }
435
436    #[test]
437    fn executed_transitions() {
438        assert!(BctsState::Executed.can_transition_to(BctsState::Recorded));
439        assert!(BctsState::Executed.can_transition_to(BctsState::Escalated));
440        assert!(!BctsState::Executed.can_transition_to(BctsState::Denied));
441    }
442
443    #[test]
444    fn recorded_transitions() {
445        assert!(BctsState::Recorded.can_transition_to(BctsState::Closed));
446        assert!(BctsState::Recorded.can_transition_to(BctsState::Escalated));
447        assert!(!BctsState::Recorded.can_transition_to(BctsState::Denied));
448    }
449
450    #[test]
451    fn closed_is_terminal() {
452        assert!(BctsState::Closed.valid_transitions().is_empty());
453        assert!(!BctsState::Closed.can_transition_to(BctsState::Draft));
454    }
455
456    #[test]
457    fn denied_transitions() {
458        assert!(BctsState::Denied.can_transition_to(BctsState::Remediated));
459        assert!(!BctsState::Denied.can_transition_to(BctsState::Closed));
460    }
461
462    #[test]
463    fn escalated_transitions() {
464        assert!(BctsState::Escalated.can_transition_to(BctsState::Deliberated));
465        assert!(BctsState::Escalated.can_transition_to(BctsState::Denied));
466        assert!(BctsState::Escalated.can_transition_to(BctsState::Remediated));
467    }
468
469    #[test]
470    fn remediated_transitions() {
471        assert!(BctsState::Remediated.can_transition_to(BctsState::Submitted));
472        assert!(!BctsState::Remediated.can_transition_to(BctsState::Closed));
473    }
474
475    #[test]
476    fn state_serde_roundtrip() {
477        let s = BctsState::Governed;
478        let json = serde_json::to_string(&s).expect("ser");
479        let s2: BctsState = serde_json::from_str(&json).expect("de");
480        assert_eq!(s, s2);
481    }
482
483    #[test]
484    fn state_ord() {
485        // Just ensure Ord is implemented and doesn't panic
486        let mut states = vec![BctsState::Closed, BctsState::Draft, BctsState::Executed];
487        states.sort();
488        // We don't care about the specific order, just that it's deterministic
489        let mut states2 = vec![BctsState::Closed, BctsState::Draft, BctsState::Executed];
490        states2.sort();
491        assert_eq!(states, states2);
492    }
493
494    // -- Transaction -------------------------------------------------------
495
496    #[test]
497    fn new_transaction_is_draft() {
498        let cid = correlation_id!();
499        let tx = Transaction::new(cid);
500        assert_eq!(tx.state(), BctsState::Draft);
501        assert!(tx.receipt_chain().is_empty());
502        assert!(tx.transitions().is_empty());
503        assert_eq!(*tx.correlation_id(), cid);
504    }
505
506    #[test]
507    fn transition_invokes_adjudicator_before_state_mutation() {
508        struct DenyingAdjudicator;
509
510        impl BctsTransitionAdjudicator for DenyingAdjudicator {
511            fn adjudicate_transition(&self, _request: &BctsTransitionRequest) -> Result<()> {
512                Err(ExoError::InvariantViolation {
513                    description: "test denial".into(),
514                })
515            }
516        }
517
518        let mut clock = test_clock();
519        let actor = test_did();
520        let mut tx = Transaction::new(correlation_id!());
521
522        let err = tx
523            .transition(
524                BctsState::Submitted,
525                &actor,
526                &mut clock,
527                &DenyingAdjudicator,
528            )
529            .expect_err("transition must fail before mutation when adjudication denies");
530
531        assert!(matches!(err, ExoError::InvariantViolation { .. }));
532        assert_eq!(tx.state(), BctsState::Draft);
533        assert!(tx.receipt_chain().is_empty());
534        assert!(tx.transitions().is_empty());
535    }
536
537    #[test]
538    fn transition_supplies_canonical_request_to_adjudicator() {
539        use std::cell::RefCell;
540
541        struct RecordingAdjudicator {
542            request: RefCell<Option<BctsTransitionRequest>>,
543        }
544
545        impl BctsTransitionAdjudicator for RecordingAdjudicator {
546            fn adjudicate_transition(&self, request: &BctsTransitionRequest) -> Result<()> {
547                self.request.replace(Some(request.clone()));
548                Ok(())
549            }
550        }
551
552        let mut clock = test_clock();
553        let actor = test_did();
554        let correlation_id = correlation_id!();
555        let mut tx = Transaction::new(correlation_id);
556        let adjudicator = RecordingAdjudicator {
557            request: RefCell::new(None),
558        };
559
560        tx.transition(BctsState::Submitted, &actor, &mut clock, &adjudicator)
561            .expect("transition ok");
562
563        let request = adjudicator
564            .request
565            .take()
566            .expect("adjudicator received request");
567        assert_eq!(request.correlation_id, correlation_id);
568        assert_eq!(request.from_state, BctsState::Draft);
569        assert_eq!(request.to_state, BctsState::Submitted);
570        assert_eq!(request.actor_did, actor);
571        assert_eq!(request.prior_receipt_hash, Hash256::ZERO);
572    }
573
574    #[test]
575    fn transition_source_invokes_adjudicator_before_hlc_and_mutation() {
576        let source = include_str!("bcts.rs");
577        let implementation = source
578            .split("impl BailmentTransaction for Transaction")
579            .nth(1)
580            .expect("transaction impl");
581        let adjudicator_call = implementation
582            .find("adjudicator.adjudicate_transition")
583            .expect("adjudicator call");
584        let hlc_tick = implementation.find("clock.now()").expect("HLC tick");
585        let mutation = implementation
586            .find("self.current_state = to")
587            .expect("state mutation");
588
589        assert!(
590            adjudicator_call < hlc_tick,
591            "BCTS must adjudicate before consuming an HLC tick"
592        );
593        assert!(
594            adjudicator_call < mutation,
595            "BCTS must adjudicate before mutating state"
596        );
597    }
598
599    #[test]
600    fn happy_path_full_lifecycle() {
601        let mut clock = test_clock();
602        let actor = test_did();
603        let mut tx = Transaction::new(correlation_id!());
604
605        let steps = [
606            BctsState::Submitted,
607            BctsState::IdentityResolved,
608            BctsState::ConsentValidated,
609            BctsState::Deliberated,
610            BctsState::Verified,
611            BctsState::Governed,
612            BctsState::Approved,
613            BctsState::Executed,
614            BctsState::Recorded,
615            BctsState::Closed,
616        ];
617
618        for (i, &target) in steps.iter().enumerate() {
619            let t = tx
620                .transition(target, &actor, &mut clock, &AllowAllAdjudicator)
621                .expect("transition ok");
622            assert_eq!(t.to_state, target);
623            assert_eq!(tx.state(), target);
624            assert_eq!(tx.receipt_chain().len(), i + 1);
625        }
626
627        // Verify full chain integrity
628        tx.verify_receipt_chain().expect("chain valid");
629    }
630
631    #[test]
632    fn invalid_transition_from_draft_to_closed() {
633        let mut clock = test_clock();
634        let actor = test_did();
635        let mut tx = Transaction::new(correlation_id!());
636
637        let err = tx
638            .transition(BctsState::Closed, &actor, &mut clock, &AllowAllAdjudicator)
639            .unwrap_err();
640        assert!(matches!(err, ExoError::InvalidTransition { .. }));
641        // State should not have changed
642        assert_eq!(tx.state(), BctsState::Draft);
643    }
644
645    #[test]
646    fn invalid_transition_from_closed() {
647        let mut clock = test_clock();
648        let actor = test_did();
649        let mut tx = Transaction::new(correlation_id!());
650
651        // Go to Closed via happy path
652        for &s in &[
653            BctsState::Submitted,
654            BctsState::IdentityResolved,
655            BctsState::ConsentValidated,
656            BctsState::Deliberated,
657            BctsState::Verified,
658            BctsState::Governed,
659            BctsState::Approved,
660            BctsState::Executed,
661            BctsState::Recorded,
662            BctsState::Closed,
663        ] {
664            tx.transition(s, &actor, &mut clock, &AllowAllAdjudicator)
665                .expect("ok");
666        }
667
668        // Closed is terminal
669        let err = tx
670            .transition(BctsState::Draft, &actor, &mut clock, &AllowAllAdjudicator)
671            .unwrap_err();
672        assert!(matches!(err, ExoError::InvalidTransition { .. }));
673    }
674
675    #[test]
676    fn denial_path() {
677        let mut clock = test_clock();
678        let actor = test_did();
679        let mut tx = Transaction::new(correlation_id!());
680
681        tx.transition(
682            BctsState::Submitted,
683            &actor,
684            &mut clock,
685            &AllowAllAdjudicator,
686        )
687        .expect("ok");
688        tx.transition(BctsState::Denied, &actor, &mut clock, &AllowAllAdjudicator)
689            .expect("ok");
690        assert_eq!(tx.state(), BctsState::Denied);
691        tx.verify_receipt_chain().expect("chain valid");
692    }
693
694    #[test]
695    fn escalation_path() {
696        let mut clock = test_clock();
697        let actor = test_did();
698        let mut tx = Transaction::new(correlation_id!());
699
700        for &s in &[
701            BctsState::Submitted,
702            BctsState::IdentityResolved,
703            BctsState::ConsentValidated,
704            BctsState::Deliberated,
705            BctsState::Escalated,
706            BctsState::Deliberated,
707            BctsState::Verified,
708        ] {
709            tx.transition(s, &actor, &mut clock, &AllowAllAdjudicator)
710                .expect("ok");
711        }
712        assert_eq!(tx.state(), BctsState::Verified);
713        tx.verify_receipt_chain().expect("chain valid");
714    }
715
716    #[test]
717    fn remediation_path() {
718        let mut clock = test_clock();
719        let actor = test_did();
720        let mut tx = Transaction::new(correlation_id!());
721
722        tx.transition(
723            BctsState::Submitted,
724            &actor,
725            &mut clock,
726            &AllowAllAdjudicator,
727        )
728        .expect("ok");
729        tx.transition(BctsState::Denied, &actor, &mut clock, &AllowAllAdjudicator)
730            .expect("ok");
731        tx.transition(
732            BctsState::Remediated,
733            &actor,
734            &mut clock,
735            &AllowAllAdjudicator,
736        )
737        .expect("ok");
738        tx.transition(
739            BctsState::Submitted,
740            &actor,
741            &mut clock,
742            &AllowAllAdjudicator,
743        )
744        .expect("ok");
745        assert_eq!(tx.state(), BctsState::Submitted);
746        tx.verify_receipt_chain().expect("chain valid");
747    }
748
749    #[test]
750    fn receipt_chain_grows_monotonically() {
751        let mut clock = test_clock();
752        let actor = test_did();
753        let mut tx = Transaction::new(correlation_id!());
754
755        tx.transition(
756            BctsState::Submitted,
757            &actor,
758            &mut clock,
759            &AllowAllAdjudicator,
760        )
761        .expect("ok");
762        assert_eq!(tx.receipt_chain().len(), 1);
763
764        tx.transition(
765            BctsState::IdentityResolved,
766            &actor,
767            &mut clock,
768            &AllowAllAdjudicator,
769        )
770        .expect("ok");
771        assert_eq!(tx.receipt_chain().len(), 2);
772
773        // Each receipt should be unique
774        assert_ne!(tx.receipt_chain()[0], tx.receipt_chain()[1]);
775    }
776
777    #[test]
778    fn transition_records_correct_actor() {
779        let mut clock = test_clock();
780        let actor = Did::new("did:exo:alice").expect("valid");
781        let mut tx = Transaction::new(correlation_id!());
782
783        let t = tx
784            .transition(
785                BctsState::Submitted,
786                &actor,
787                &mut clock,
788                &AllowAllAdjudicator,
789            )
790            .expect("ok");
791        assert_eq!(t.actor_did, actor);
792    }
793
794    #[test]
795    fn transition_timestamps_are_monotonic() {
796        let mut clock = test_clock();
797        let actor = test_did();
798        let mut tx = Transaction::new(correlation_id!());
799
800        tx.transition(
801            BctsState::Submitted,
802            &actor,
803            &mut clock,
804            &AllowAllAdjudicator,
805        )
806        .expect("ok");
807        tx.transition(
808            BctsState::IdentityResolved,
809            &actor,
810            &mut clock,
811            &AllowAllAdjudicator,
812        )
813        .expect("ok");
814
815        let ts = &tx.transitions();
816        assert!(ts[0].timestamp < ts[1].timestamp);
817    }
818
819    #[test]
820    fn verify_receipt_chain_detects_tampering() {
821        let mut clock = test_clock();
822        let actor = test_did();
823        let mut tx = Transaction::new(correlation_id!());
824
825        tx.transition(
826            BctsState::Submitted,
827            &actor,
828            &mut clock,
829            &AllowAllAdjudicator,
830        )
831        .expect("ok");
832        tx.transition(
833            BctsState::IdentityResolved,
834            &actor,
835            &mut clock,
836            &AllowAllAdjudicator,
837        )
838        .expect("ok");
839
840        // Tamper with a receipt
841        tx.receipt_chain[0] = Hash256::ZERO;
842        let err = tx.verify_receipt_chain().unwrap_err();
843        assert!(matches!(err, ExoError::ReceiptChainBroken { index: 0 }));
844    }
845
846    #[test]
847    fn transaction_serde_roundtrip() {
848        let mut clock = test_clock();
849        let actor = test_did();
850        let mut tx = Transaction::new(correlation_id!());
851        tx.transition(
852            BctsState::Submitted,
853            &actor,
854            &mut clock,
855            &AllowAllAdjudicator,
856        )
857        .expect("ok");
858
859        let json = serde_json::to_string(&tx).expect("ser");
860        let tx2: Transaction = serde_json::from_str(&json).expect("de");
861        assert_eq!(tx.state(), tx2.state());
862        assert_eq!(tx.receipt_chain(), tx2.receipt_chain());
863        assert_eq!(tx.correlation_id(), tx2.correlation_id());
864    }
865
866    #[test]
867    fn every_invalid_transition_from_each_state() {
868        // Exhaustively test that no invalid transition is accepted.
869        let all_states = [
870            BctsState::Draft,
871            BctsState::Submitted,
872            BctsState::IdentityResolved,
873            BctsState::ConsentValidated,
874            BctsState::Deliberated,
875            BctsState::Verified,
876            BctsState::Governed,
877            BctsState::Approved,
878            BctsState::Executed,
879            BctsState::Recorded,
880            BctsState::Closed,
881            BctsState::Denied,
882            BctsState::Escalated,
883            BctsState::Remediated,
884        ];
885
886        for &from in &all_states {
887            let valid = from.valid_transitions();
888            for &to in &all_states {
889                if valid.contains(&to) {
890                    assert!(
891                        from.can_transition_to(to),
892                        "{from} should be able to transition to {to}"
893                    );
894                } else {
895                    assert!(
896                        !from.can_transition_to(to),
897                        "{from} should NOT be able to transition to {to}"
898                    );
899                }
900            }
901        }
902    }
903
904    #[test]
905    fn escalated_to_denied_to_remediated_to_submitted() {
906        let mut clock = test_clock();
907        let actor = test_did();
908        let mut tx = Transaction::new(correlation_id!());
909
910        for &s in &[
911            BctsState::Submitted,
912            BctsState::IdentityResolved,
913            BctsState::ConsentValidated,
914            BctsState::Deliberated,
915            BctsState::Escalated,
916            BctsState::Denied,
917            BctsState::Remediated,
918            BctsState::Submitted,
919        ] {
920            tx.transition(s, &actor, &mut clock, &AllowAllAdjudicator)
921                .expect("ok");
922        }
923        assert_eq!(tx.state(), BctsState::Submitted);
924        tx.verify_receipt_chain().expect("chain valid");
925    }
926
927    #[test]
928    fn escalated_to_remediated() {
929        let mut clock = test_clock();
930        let actor = test_did();
931        let mut tx = Transaction::new(correlation_id!());
932
933        for &s in &[
934            BctsState::Submitted,
935            BctsState::IdentityResolved,
936            BctsState::ConsentValidated,
937            BctsState::Deliberated,
938            BctsState::Escalated,
939            BctsState::Remediated,
940            BctsState::Submitted,
941        ] {
942            tx.transition(s, &actor, &mut clock, &AllowAllAdjudicator)
943                .expect("ok");
944        }
945        tx.verify_receipt_chain().expect("chain valid");
946    }
947
948    #[test]
949    fn bcts_state_labels_do_not_depend_on_debug_formatting() {
950        assert_eq!(BctsState::Submitted.as_str(), "Submitted");
951        assert_eq!(BctsState::Submitted.to_string(), "Submitted");
952
953        let source = include_str!("bcts.rs");
954        let production = source
955            .split("#[cfg(test)]")
956            .next()
957            .expect("production section");
958        assert!(
959            !production.contains("write!(f, \"{self:?}\")"),
960            "BCTS display output must use explicit stable labels"
961        );
962    }
963}