Skip to main content

rustev_live/
gate.rs

1//! Journal-backed spend gate (spec 043, 3.6).
2
3use rustev_contract::run::{Charge, CostBound};
4use rustev_runtime::{SpendAttempt, SpendGate, SpendRefusal, SpendTicket, SpendUnit};
5
6use crate::journal::{BudgetKind, Reservation, SpendJournal, SpendRefusal as JournalRefusal};
7
8/// Clones of the journal share the lock used to check both caps and reserve.
9/// Opening the journal remains the caller's responsibility and preserves
10/// the existing `JournalError::Locked` error.
11#[derive(Debug, Clone)]
12pub struct JournalGate {
13    journal: SpendJournal,
14    run_id: String,
15    run_cap_units: u64,
16}
17
18struct Ticket {
19    reservation: Reservation,
20    no_spend: bool,
21}
22
23impl JournalGate {
24    pub fn new(
25        journal: SpendJournal,
26        run_id: impl Into<String>,
27        run_cap_units: u64,
28    ) -> Result<Self, SpendRefusal> {
29        if journal.budget() != BudgetKind::Testing {
30            return Err(SpendRefusal::new("journal-budget"));
31        }
32        let run_id = run_id.into();
33        if run_id.is_empty()
34            || run_id.len() > 64
35            || !run_id
36                .bytes()
37                .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
38        {
39            return Err(SpendRefusal::new("live-config"));
40        }
41        Ok(Self {
42            journal,
43            run_id,
44            run_cap_units,
45        })
46    }
47
48    pub(crate) fn journal(&self) -> &SpendJournal {
49        &self.journal
50    }
51
52    pub fn run_id(&self) -> &str {
53        &self.run_id
54    }
55}
56
57impl JournalGate {
58    fn reserve_attempt(
59        &self,
60        decision_id: &str,
61        attempt_id: &str,
62        bound: CostBound,
63        no_spend: bool,
64    ) -> Result<SpendTicket, SpendRefusal> {
65        if !decision_id.starts_with(&format!("live/{}/", self.run_id)) {
66            return Err(SpendRefusal::new("live-config"));
67        }
68        let units = match bound {
69            CostBound::Bounded { max_units } => max_units,
70            CostBound::Estimated { units } => units,
71            CostBound::Unknown => return Err(SpendRefusal::new("spend-exposure-unknown")),
72        };
73
74        // The runtime's attempt id escapes separators. Prefix with the
75        // original decision identity to retain exact run attribution.
76        let key = format!("{decision_id}/{attempt_id}");
77        let reservation = self
78            .journal
79            .reserve_checked(
80                &key,
81                if no_spend { 0 } else { units },
82                Some((&self.run_id, self.run_cap_units)),
83            )
84            .map_err(|e| {
85                SpendRefusal::new(match e {
86                    JournalRefusal::RunCap => "run-cap-exceeded",
87                    JournalRefusal::OverCap { .. } => "spend-cap-exceeded",
88                    JournalRefusal::Journal(_) => "journal-io",
89                })
90            })?;
91        Ok(Box::new(Ticket {
92            reservation,
93            no_spend,
94        }))
95    }
96}
97
98impl SpendGate for JournalGate {
99    fn reserve(&self, attempt: &SpendAttempt) -> Result<SpendTicket, SpendRefusal> {
100        self.reserve_attempt(
101            &attempt.decision_id,
102            &attempt.attempt_id,
103            attempt.bound,
104            attempt.cost_unit == SpendUnit::None,
105        )
106    }
107    fn reserve_acquisition(
108        &self,
109        attempt: &rustev_runtime::AcquisitionSpendAttempt,
110    ) -> Result<SpendTicket, SpendRefusal> {
111        self.reserve_attempt(
112            &attempt.decision_id,
113            &attempt.attempt_id,
114            attempt.bound,
115            false,
116        )
117    }
118    fn settle(&self, ticket: SpendTicket, charge: &Charge) {
119        if let Ok(ticket) = ticket.downcast::<Ticket>() {
120            // Work units of a declared no-spend binding are not money.
121            let charge = if ticket.no_spend {
122                match charge {
123                    Charge::Observed { .. } => Charge::Observed { units: 0 },
124                    Charge::Estimated { .. } => Charge::Estimated { units: 0 },
125                    Charge::Unknown => Charge::Unknown,
126                }
127            } else {
128                *charge
129            };
130            self.journal.settle(ticket.reservation, charge);
131        }
132    }
133
134    fn release(&self, ticket: SpendTicket) {
135        if let Ok(ticket) = ticket.downcast::<Ticket>() {
136            self.journal.release(ticket.reservation);
137        }
138    }
139}