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    /// Applies fenced recovery while atomically materializing a pending
298    /// immutable terminal candidate. That candidate owns no `A` claim, so `RA`
299    /// still transfers into the recovered binding's new active-terminal claim.
300    pub(in crate::lifecycle) fn apply_fenced_recovery_finalizing_pending(
301        self,
302    ) -> Result<Self, OrderAdmissionError> {
303        self.apply_fenced_recovery()
304    }
305}
306
307/// Protocol-produced post-operation order claims.
308///
309/// Construction is crate-private: consuming servers restore current claims but
310/// cannot invent the simulated post-state used by admission precedence.
311#[derive(Clone, Copy, Debug, PartialEq, Eq)]
312pub struct ResultingOrderClaims(OrderClaims);
313
314impl ResultingOrderClaims {
315    const fn claims(self) -> OrderClaims {
316        self.0
317    }
318}
319
320/// Successful allocation of one unreserved transaction-order major.
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322pub struct OrderAllocation {
323    major: TransactionOrder,
324    resulting: OrderLedger,
325}
326
327impl OrderAllocation {
328    /// Returns the newly allocated major.
329    #[must_use]
330    pub const fn major(self) -> TransactionOrder {
331        self.major
332    }
333
334    /// Returns the complete validated post-allocation ledger.
335    #[must_use]
336    pub const fn resulting(self) -> OrderLedger {
337        self.resulting
338    }
339}
340
341/// Order admission refusal or impossible simulated state.
342#[derive(Clone, Debug, PartialEq, Eq)]
343pub enum OrderAdmissionError {
344    /// Canonical wire-visible order exhaustion.
345    Exhausted(Box<ConversationOrderExhausted>),
346    /// A protocol simulation produced more claims than the initial suffix can own.
347    InitialClaimsExceedRemaining {
348        /// Remaining values after allocating major zero.
349        remaining: u128,
350        /// Simulated resulting claim count.
351        resulting_claims: u128,
352    },
353    /// Adding an active binding-terminal claim would exceed u64.
354    ActiveBindingClaimOverflow,
355    /// Adding a membership-exit claim would exceed u64.
356    MembershipExitClaimOverflow,
357    /// A fixed-point projection attempted to mint a second `RO`/`RA` pair.
358    RecoveryOrderReserveAlreadyPresent,
359    /// Fenced recovery lacks one or both coupled reserved order claims.
360    RecoveryOrderReserveMissing {
361        /// Whether the current edge owns recovery-operation claim `RO`.
362        recovery_operation: bool,
363        /// Whether the current edge owns replacement-terminal claim `RA`.
364        recovery_replacement_terminal: bool,
365    },
366}
367
368/// Allocates one caller major after applying the sealed simulated claim state.
369///
370/// External callers cannot construct [`ResultingOrderClaims`]; it is emitted by
371/// protocol-owned lifecycle simulation so the consuming server cannot invent
372/// post-operation obligations.
373///
374/// # Errors
375///
376/// Returns canonical [`OrderAdmissionError::Exhausted`] when no unreserved
377/// major remains, or an invariant error for an impossible initial simulation.
378pub fn allocate_order(
379    request: OrderAllocatingEnvelope,
380    current: OrderLedger,
381    resulting_claims: ResultingOrderClaims,
382) -> Result<OrderAllocation, OrderAdmissionError> {
383    let resulting_claims = resulting_claims.claims();
384    let resulting_claim_count = resulting_claims.total();
385    match current.high {
386        OrderHigh::Empty => {
387            let major = 0;
388            let resulting_remaining = u128::from(u64::MAX);
389            if resulting_claim_count > resulting_remaining {
390                return Err(OrderAdmissionError::InitialClaimsExceedRemaining {
391                    remaining: resulting_remaining,
392                    resulting_claims: resulting_claim_count,
393                });
394            }
395            Ok(OrderAllocation {
396                major,
397                resulting: OrderLedger {
398                    high: OrderHigh::Allocated(major),
399                    claims: resulting_claims,
400                },
401            })
402        }
403        OrderHigh::Allocated(high) => {
404            let order_remaining = current.remaining();
405            let reserved_claims = current.claims.total();
406            let next = high.checked_add(1);
407            let resulting_order_remaining = next.map_or(0, |_| order_remaining - 1);
408            let Some(major) = next else {
409                return Err(OrderAdmissionError::Exhausted(Box::new(
410                    ConversationOrderExhausted::new(
411                        request,
412                        high,
413                        order_remaining,
414                        reserved_claims,
415                        resulting_order_remaining,
416                        resulting_claim_count,
417                    ),
418                )));
419            };
420            if resulting_claim_count > resulting_order_remaining {
421                return Err(OrderAdmissionError::Exhausted(Box::new(
422                    ConversationOrderExhausted::new(
423                        request,
424                        high,
425                        order_remaining,
426                        reserved_claims,
427                        resulting_order_remaining,
428                        resulting_claim_count,
429                    ),
430                )));
431            }
432            Ok(OrderAllocation {
433                major,
434                resulting: OrderLedger {
435                    high: OrderHigh::Allocated(major),
436                    claims: resulting_claims,
437                },
438            })
439        }
440    }
441}
442
443fn remaining_after(high: OrderHigh) -> u128 {
444    match high {
445        OrderHigh::Empty => u128::from(u64::MAX) + 1,
446        OrderHigh::Allocated(high) => u128::from(u64::MAX - high),
447    }
448}