Skip to main content

liminal_protocol/lifecycle/admission/
order.rs

1use alloc::boxed::Box;
2
3use crate::wire::{ConversationOrderExhausted, OrderAllocatingEnvelope, TransactionOrder};
4
5/// Movable unmaterialized transaction-order claims.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub struct OrderClaims {
8    active_binding_terminals: u64,
9    membership_exits: u64,
10    recovery_operation: bool,
11    recovery_replacement_terminal: bool,
12}
13
14impl OrderClaims {
15    /// Creates the four exact `A`, `X`, `RO`, and `RA` claim classes only when
16    /// the recovery pair is coupled.
17    ///
18    /// # Errors
19    ///
20    /// Returns [`OrderClaimsInvariantError::RecoveryPairMismatch`] when exactly
21    /// one of `RO` and `RA` is present.
22    pub const fn new(
23        active_binding_terminals: u64,
24        membership_exits: u64,
25        recovery_operation: bool,
26        recovery_replacement_terminal: bool,
27    ) -> Result<Self, OrderClaimsInvariantError> {
28        if recovery_operation != recovery_replacement_terminal {
29            return Err(OrderClaimsInvariantError::RecoveryPairMismatch {
30                recovery_operation,
31                recovery_replacement_terminal,
32            });
33        }
34        Ok(Self {
35            active_binding_terminals,
36            membership_exits,
37            recovery_operation,
38            recovery_replacement_terminal,
39        })
40    }
41
42    /// Returns active binding-terminal claims `A`.
43    #[must_use]
44    pub const fn active_binding_terminals(self) -> u64 {
45        self.active_binding_terminals
46    }
47
48    /// Returns live membership-exit claims `X`.
49    #[must_use]
50    pub const fn membership_exits(self) -> u64 {
51        self.membership_exits
52    }
53
54    /// Returns whether the stored edge owns recovery-operation claim `RO`.
55    #[must_use]
56    pub const fn recovery_operation(self) -> bool {
57        self.recovery_operation
58    }
59
60    /// Returns whether the stored edge owns replacement-terminal claim `RA`.
61    #[must_use]
62    pub const fn recovery_replacement_terminal(self) -> bool {
63        self.recovery_replacement_terminal
64    }
65
66    /// Returns checked-wide `A + X + RO + RA`.
67    #[must_use]
68    pub fn total(self) -> u128 {
69        u128::from(self.active_binding_terminals)
70            + u128::from(self.membership_exits)
71            + u128::from(self.recovery_operation)
72            + u128::from(self.recovery_replacement_terminal)
73    }
74}
75
76/// Invalid primitive order-claim state.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum OrderClaimsInvariantError {
79    /// The sole recovery-operation and replacement-terminal claims are coupled.
80    RecoveryPairMismatch {
81        /// Whether `RO` was present.
82        recovery_operation: bool,
83        /// Whether `RA` was present.
84        recovery_replacement_terminal: bool,
85    },
86}
87
88/// Persisted transaction-order high-watermark state.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum OrderHigh {
91    /// No major has been allocated; all `2^64` values remain.
92    #[default]
93    Empty,
94    /// Highest major already allocated exactly once.
95    Allocated(TransactionOrder),
96}
97
98/// Invalid persisted order ledger.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum OrderLedgerInvariantError {
101    /// Unmaterialized claims exceed the exact remaining counter suffix.
102    ClaimsExceedRemaining {
103        /// Exact remaining values.
104        remaining: u128,
105        /// Exact four-class claim count.
106        claims: u128,
107    },
108}
109
110/// Validated transaction-order high watermark and reserved claims.
111///
112/// Storage bindings may restore and inspect this factual snapshot, but cannot
113/// invoke the lower-level planner directly. Executable allocation is owned by
114/// the protocol's total lifecycle operations.
115///
116/// ```compile_fail
117/// use liminal_protocol::lifecycle::OrderLedger;
118///
119/// fn bypass_total_operation(ledger: OrderLedger) {
120///     let _ = ledger.plan_ordinary_record();
121/// }
122/// ```
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct OrderLedger {
125    high: OrderHigh,
126    claims: OrderClaims,
127}
128
129impl OrderLedger {
130    /// Restores a ledger only when every unmaterialized claim owns a remaining value.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`OrderLedgerInvariantError`] if claims exceed the exact suffix.
135    pub fn try_new(
136        high: OrderHigh,
137        claims: OrderClaims,
138    ) -> Result<Self, OrderLedgerInvariantError> {
139        let remaining = remaining_after(high);
140        let claim_count = claims.total();
141        if claim_count > remaining {
142            return Err(OrderLedgerInvariantError::ClaimsExceedRemaining {
143                remaining,
144                claims: claim_count,
145            });
146        }
147        Ok(Self { high, claims })
148    }
149
150    /// Returns the persisted high-watermark state.
151    #[must_use]
152    pub const fn high(self) -> OrderHigh {
153        self.high
154    }
155
156    /// Returns the exact four-class claim state.
157    #[must_use]
158    pub const fn claims(self) -> OrderClaims {
159        self.claims
160    }
161
162    /// Returns exact unallocated majors remaining.
163    #[must_use]
164    pub fn remaining(self) -> u128 {
165        remaining_after(self.high)
166    }
167
168    /// Plans optional enrollment's `A+1, X+1` claim transition.
169    ///
170    /// Existing `RO` and `RA` claims are preserved. The returned claim state is
171    /// sealed and can only be consumed by [`allocate_order`].
172    ///
173    /// # Errors
174    ///
175    /// Returns [`OrderAdmissionError::ActiveBindingClaimOverflow`] or
176    /// [`OrderAdmissionError::MembershipExitClaimOverflow`] for the first
177    /// checked addition that cannot be represented.
178    #[cfg(test)]
179    pub(crate) fn plan_enrollment(self) -> Result<ResultingOrderClaims, OrderAdmissionError> {
180        self.plan_enrollment_with_recovery_quartet(false)
181    }
182
183    /// Plans enrollment while allowing the protocol-owned closure projection to
184    /// endow the episode's sole `RO`/`RA` pair.
185    ///
186    /// The boolean is crate-private deliberately: only the fixed-point planner
187    /// may derive it from durable marker/binding facts. A storage binding cannot
188    /// request a quartet directly.
189    pub(crate) fn plan_enrollment_with_recovery_quartet(
190        self,
191        endow_recovery_quartet: bool,
192    ) -> Result<ResultingOrderClaims, OrderAdmissionError> {
193        self.ensure_quartet_can_be_endowed(endow_recovery_quartet)?;
194        let active_binding_terminals = self
195            .claims
196            .active_binding_terminals
197            .checked_add(1)
198            .ok_or(OrderAdmissionError::ActiveBindingClaimOverflow)?;
199        let membership_exits = self
200            .claims
201            .membership_exits
202            .checked_add(1)
203            .ok_or(OrderAdmissionError::MembershipExitClaimOverflow)?;
204        Ok(ResultingOrderClaims(OrderClaims {
205            active_binding_terminals,
206            membership_exits,
207            recovery_operation: self.claims.recovery_operation || endow_recovery_quartet,
208            recovery_replacement_terminal: self.claims.recovery_replacement_terminal
209                || endow_recovery_quartet,
210        }))
211    }
212
213    /// Plans optional detached attach's `A+1` claim transition.
214    ///
215    /// Membership and both recovery order claims are preserved.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`OrderAdmissionError::ActiveBindingClaimOverflow`] if the new
220    /// terminal claim cannot be represented.
221    #[cfg(test)]
222    pub(crate) fn plan_detached_attach(self) -> Result<ResultingOrderClaims, OrderAdmissionError> {
223        let active_binding_terminals = self
224            .claims
225            .active_binding_terminals
226            .checked_add(1)
227            .ok_or(OrderAdmissionError::ActiveBindingClaimOverflow)?;
228        Ok(ResultingOrderClaims(OrderClaims {
229            active_binding_terminals,
230            ..self.claims
231        }))
232    }
233
234    /// Plans supersession's transfer of the existing `A` claim.
235    ///
236    /// The aggregate `A`, `X`, `RO`, and `RA` counts are unchanged: the old
237    /// binding's terminal claim moves to the replacement binding.
238    #[cfg(test)]
239    #[must_use]
240    pub(crate) const fn plan_supersession(self) -> ResultingOrderClaims {
241        ResultingOrderClaims(self.claims)
242    }
243
244    /// Plans ordinary record admission, which creates no order claim.
245    #[must_use]
246    pub(crate) const fn plan_ordinary_record(self) -> ResultingOrderClaims {
247        ResultingOrderClaims(self.claims)
248    }
249
250    const fn ensure_quartet_can_be_endowed(
251        self,
252        endow_recovery_quartet: bool,
253    ) -> Result<(), OrderAdmissionError> {
254        if endow_recovery_quartet
255            && (self.claims.recovery_operation || self.claims.recovery_replacement_terminal)
256        {
257            return Err(OrderAdmissionError::RecoveryOrderReserveAlreadyPresent);
258        }
259        Ok(())
260    }
261
262    /// Applies fenced recovery using only its pre-owned `RO` and `RA` claims.
263    ///
264    /// `RO` is consumed by the recovery operation and `RA` transfers into one
265    /// new active binding-terminal `A` claim. No caller major is allocated, so
266    /// the order high watermark is unchanged.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`OrderAdmissionError::RecoveryOrderReserveMissing`] unless both
271    /// coupled recovery claims exist, or
272    /// [`OrderAdmissionError::ActiveBindingClaimOverflow`] if `RA` cannot be
273    /// transferred into `A`.
274    pub(in crate::lifecycle) fn apply_fenced_recovery(self) -> Result<Self, OrderAdmissionError> {
275        if !self.claims.recovery_operation || !self.claims.recovery_replacement_terminal {
276            return Err(OrderAdmissionError::RecoveryOrderReserveMissing {
277                recovery_operation: self.claims.recovery_operation,
278                recovery_replacement_terminal: self.claims.recovery_replacement_terminal,
279            });
280        }
281        let active_binding_terminals = self
282            .claims
283            .active_binding_terminals
284            .checked_add(1)
285            .ok_or(OrderAdmissionError::ActiveBindingClaimOverflow)?;
286        Ok(Self {
287            high: self.high,
288            claims: OrderClaims {
289                active_binding_terminals,
290                membership_exits: self.claims.membership_exits,
291                recovery_operation: false,
292                recovery_replacement_terminal: false,
293            },
294        })
295    }
296}
297
298/// Protocol-produced post-operation order claims.
299///
300/// Construction is crate-private: consuming servers restore current claims but
301/// cannot invent the simulated post-state used by admission precedence.
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub struct ResultingOrderClaims(OrderClaims);
304
305impl ResultingOrderClaims {
306    const fn claims(self) -> OrderClaims {
307        self.0
308    }
309}
310
311/// Successful allocation of one unreserved transaction-order major.
312#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub struct OrderAllocation {
314    major: TransactionOrder,
315    resulting: OrderLedger,
316}
317
318impl OrderAllocation {
319    /// Returns the newly allocated major.
320    #[must_use]
321    pub const fn major(self) -> TransactionOrder {
322        self.major
323    }
324
325    /// Returns the complete validated post-allocation ledger.
326    #[must_use]
327    pub const fn resulting(self) -> OrderLedger {
328        self.resulting
329    }
330}
331
332/// Order admission refusal or impossible simulated state.
333#[derive(Clone, Debug, PartialEq, Eq)]
334pub enum OrderAdmissionError {
335    /// Canonical wire-visible order exhaustion.
336    Exhausted(Box<ConversationOrderExhausted>),
337    /// A protocol simulation produced more claims than the initial suffix can own.
338    InitialClaimsExceedRemaining {
339        /// Remaining values after allocating major zero.
340        remaining: u128,
341        /// Simulated resulting claim count.
342        resulting_claims: u128,
343    },
344    /// Adding an active binding-terminal claim would exceed u64.
345    ActiveBindingClaimOverflow,
346    /// Adding a membership-exit claim would exceed u64.
347    MembershipExitClaimOverflow,
348    /// A fixed-point projection attempted to mint a second `RO`/`RA` pair.
349    RecoveryOrderReserveAlreadyPresent,
350    /// Fenced recovery lacks one or both coupled reserved order claims.
351    RecoveryOrderReserveMissing {
352        /// Whether the current edge owns recovery-operation claim `RO`.
353        recovery_operation: bool,
354        /// Whether the current edge owns replacement-terminal claim `RA`.
355        recovery_replacement_terminal: bool,
356    },
357}
358
359/// Allocates one caller major after applying the sealed simulated claim state.
360///
361/// External callers cannot construct [`ResultingOrderClaims`]; it is emitted by
362/// protocol-owned lifecycle simulation so the consuming server cannot invent
363/// post-operation obligations.
364///
365/// # Errors
366///
367/// Returns canonical [`OrderAdmissionError::Exhausted`] when no unreserved
368/// major remains, or an invariant error for an impossible initial simulation.
369pub fn allocate_order(
370    request: OrderAllocatingEnvelope,
371    current: OrderLedger,
372    resulting_claims: ResultingOrderClaims,
373) -> Result<OrderAllocation, OrderAdmissionError> {
374    let resulting_claims = resulting_claims.claims();
375    let resulting_claim_count = resulting_claims.total();
376    match current.high {
377        OrderHigh::Empty => {
378            let major = 0;
379            let resulting_remaining = u128::from(u64::MAX);
380            if resulting_claim_count > resulting_remaining {
381                return Err(OrderAdmissionError::InitialClaimsExceedRemaining {
382                    remaining: resulting_remaining,
383                    resulting_claims: resulting_claim_count,
384                });
385            }
386            Ok(OrderAllocation {
387                major,
388                resulting: OrderLedger {
389                    high: OrderHigh::Allocated(major),
390                    claims: resulting_claims,
391                },
392            })
393        }
394        OrderHigh::Allocated(high) => {
395            let order_remaining = current.remaining();
396            let reserved_claims = current.claims.total();
397            let next = high.checked_add(1);
398            let resulting_order_remaining = next.map_or(0, |_| order_remaining - 1);
399            let Some(major) = next else {
400                return Err(OrderAdmissionError::Exhausted(Box::new(
401                    ConversationOrderExhausted::new(
402                        request,
403                        high,
404                        order_remaining,
405                        reserved_claims,
406                        resulting_order_remaining,
407                        resulting_claim_count,
408                    ),
409                )));
410            };
411            if resulting_claim_count > resulting_order_remaining {
412                return Err(OrderAdmissionError::Exhausted(Box::new(
413                    ConversationOrderExhausted::new(
414                        request,
415                        high,
416                        order_remaining,
417                        reserved_claims,
418                        resulting_order_remaining,
419                        resulting_claim_count,
420                    ),
421                )));
422            }
423            Ok(OrderAllocation {
424                major,
425                resulting: OrderLedger {
426                    high: OrderHigh::Allocated(major),
427                    claims: resulting_claims,
428                },
429            })
430        }
431    }
432}
433
434fn remaining_after(high: OrderHigh) -> u128 {
435    match high {
436        OrderHigh::Empty => u128::from(u64::MAX) + 1,
437        OrderHigh::Allocated(high) => u128::from(u64::MAX - high),
438    }
439}