Skip to main content

liminal_protocol/lifecycle/
closure_accounting.rs

1use alloc::boxed::Box;
2
3use crate::algebra::{ResourceDimension, ResourceVector, WideResourceVector};
4use crate::wire::{
5    ClosureCapacityReason, ClosureCheckedEnvelope, ClosureRefusalReason, ClosureSnapshot,
6    MarkerClosureCapacityExceeded, ParticipantCursorProgressEdge, RepaymentEdge,
7};
8
9use super::{ClosureState, StoredEdge};
10
11/// Invalid durable closure-accounting state.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum ClosureAccountingError {
14    /// The signed churn limit must be nonzero.
15    ZeroChurnLimit,
16    /// Durable churn use exceeds the signed limit.
17    ChurnUsedExceedsLimit {
18        /// Durable cycles already used.
19        used: u64,
20        /// Signed episode limit.
21        limit: u64,
22    },
23    /// Marker anchors cannot outnumber slots owning marker credits.
24    MarkerAnchorsExceedCredits {
25        /// Current distinct marker anchors.
26        anchors: u64,
27        /// Current marker-capacity credits.
28        credits: u64,
29    },
30    /// The durable baseline exceeds the configured capacity.
31    BaselineExceedsCapacity {
32        /// First failing component.
33        dimension: ResourceDimension,
34    },
35    /// A clear edge retained edge-owned claims or recovery occupancy.
36    ClearStateOwnsEdgeResources,
37    /// A nonzero debt component cannot fit the frozen wire snapshot width.
38    DebtOutsideWireDomain {
39        /// First unencodable component.
40        dimension: ResourceDimension,
41    },
42}
43
44/// Validated unchanged-prestate closure accounting.
45///
46/// This wrapper owns every common field disclosed by a closure refusal. The
47/// wire snapshot is derived from it, so a server binding cannot mix current and
48/// proposed values or hand-construct an operation-specific refusal.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct ClosureAccounting {
51    state: ClosureState,
52    marker_capacity_credits: u64,
53    marker_anchors: u64,
54    edge_sequence_claims: u64,
55    edge_order_position_claims: u64,
56    edge_k_remaining: ResourceVector,
57    baseline: WideResourceVector,
58    configured_cap: ResourceVector,
59    episode_churn_used: u64,
60    episode_churn_limit: u64,
61}
62
63impl ClosureAccounting {
64    /// Validates one complete durable accounting snapshot.
65    ///
66    /// The edge-owned claim counts remain explicit durable facts because their
67    /// exact occurrence plan is deliberately not the defective fixed array
68    /// excluded by `docs/design/LP-EXTRACTION-GOAL.md` Fix 2.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ClosureAccountingError`] for a zero/overused churn bound,
73    /// impossible anchor count, baseline outside capacity, edge resources in a
74    /// clear state, or debt that cannot use the frozen `u64` wire fields.
75    #[allow(clippy::too_many_arguments)]
76    pub fn try_new(
77        state: ClosureState,
78        marker_capacity_credits: u64,
79        marker_anchors: u64,
80        edge_sequence_claims: u64,
81        edge_order_position_claims: u64,
82        edge_k_remaining: ResourceVector,
83        baseline: WideResourceVector,
84        configured_cap: ResourceVector,
85        episode_churn_used: u64,
86        episode_churn_limit: u64,
87    ) -> Result<Self, ClosureAccountingError> {
88        if episode_churn_limit == 0 {
89            return Err(ClosureAccountingError::ZeroChurnLimit);
90        }
91        if episode_churn_used > episode_churn_limit {
92            return Err(ClosureAccountingError::ChurnUsedExceedsLimit {
93                used: episode_churn_used,
94                limit: episode_churn_limit,
95            });
96        }
97        if marker_anchors > marker_capacity_credits {
98            return Err(ClosureAccountingError::MarkerAnchorsExceedCredits {
99                anchors: marker_anchors,
100                credits: marker_capacity_credits,
101            });
102        }
103        if baseline.entries > u128::from(configured_cap.entries) {
104            return Err(ClosureAccountingError::BaselineExceedsCapacity {
105                dimension: ResourceDimension::Entries,
106            });
107        }
108        if baseline.bytes > u128::from(configured_cap.bytes) {
109            return Err(ClosureAccountingError::BaselineExceedsCapacity {
110                dimension: ResourceDimension::Bytes,
111            });
112        }
113        match state {
114            ClosureState::Clear => {
115                if edge_sequence_claims != 0
116                    || edge_order_position_claims != 0
117                    || edge_k_remaining != ResourceVector::default()
118                {
119                    return Err(ClosureAccountingError::ClearStateOwnsEdgeResources);
120                }
121            }
122            ClosureState::Owed { debt, .. } => {
123                let value = debt.value();
124                if u64::try_from(value.entries).is_err() {
125                    return Err(ClosureAccountingError::DebtOutsideWireDomain {
126                        dimension: ResourceDimension::Entries,
127                    });
128                }
129                if u64::try_from(value.bytes).is_err() {
130                    return Err(ClosureAccountingError::DebtOutsideWireDomain {
131                        dimension: ResourceDimension::Bytes,
132                    });
133                }
134            }
135        }
136        Ok(Self {
137            state,
138            marker_capacity_credits,
139            marker_anchors,
140            edge_sequence_claims,
141            edge_order_position_claims,
142            edge_k_remaining,
143            baseline,
144            configured_cap,
145            episode_churn_used,
146            episode_churn_limit,
147        })
148    }
149
150    /// Returns the typed clear-or-owed closure state.
151    #[must_use]
152    pub const fn state(self) -> ClosureState {
153        self.state
154    }
155
156    /// Returns identity slots currently owning marker-capacity credits.
157    #[must_use]
158    pub const fn marker_capacity_credits(self) -> u64 {
159        self.marker_capacity_credits
160    }
161
162    /// Returns current planned, undelivered, or delivered marker anchors.
163    #[must_use]
164    pub const fn marker_anchors(self) -> u64 {
165        self.marker_anchors
166    }
167
168    /// Returns sequence claims owned by the current edge.
169    #[must_use]
170    pub const fn edge_sequence_claims(self) -> u64 {
171        self.edge_sequence_claims
172    }
173
174    /// Returns transaction-order positions owned by the current edge.
175    #[must_use]
176    pub const fn edge_order_position_claims(self) -> u64 {
177        self.edge_order_position_claims
178    }
179
180    /// Returns the exact current recovery-capacity occupancy.
181    #[must_use]
182    pub const fn edge_k_remaining(self) -> ResourceVector {
183        self.edge_k_remaining
184    }
185
186    /// Returns the current retained baseline `B`.
187    #[must_use]
188    pub const fn baseline(self) -> WideResourceVector {
189        self.baseline
190    }
191
192    /// Returns the configured entry/byte capacity.
193    #[must_use]
194    pub const fn configured_cap(self) -> ResourceVector {
195        self.configured_cap
196    }
197
198    /// Returns activated churn cycles already used.
199    #[must_use]
200    pub const fn episode_churn_used(self) -> u64 {
201        self.episode_churn_used
202    }
203
204    /// Returns the signed churn-cycle limit.
205    #[must_use]
206    pub const fn episode_churn_limit(self) -> u64 {
207        self.episode_churn_limit
208    }
209
210    fn snapshot(self, delta_cycles: u64) -> ClosureSnapshot {
211        let (debt, repayment_edge) = closure_state_wire(self.state);
212        ClosureSnapshot {
213            marker_capacity_credits: self.marker_capacity_credits,
214            marker_anchors: self.marker_anchors,
215            entry_debt: debt.entries,
216            byte_debt: debt.bytes,
217            repayment_edge,
218            edge_sequence_claims: self.edge_sequence_claims,
219            edge_order_position_claims: self.edge_order_position_claims,
220            edge_k_remaining: self.edge_k_remaining,
221            k_headroom: WideResourceVector::new(
222                u128::from(self.configured_cap.entries) - self.baseline.entries,
223                u128::from(self.configured_cap.bytes) - self.baseline.bytes,
224            ),
225            episode_churn_used: self.episode_churn_used,
226            delta_cycles,
227            episode_churn_limit: self.episode_churn_limit,
228        }
229    }
230}
231
232/// Invalid required-capacity simulation.
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234pub enum RequiredCapacityPlanError {
235    /// A successor simulation supplied no reachable state.
236    EmptySuccessorSet,
237    /// A checked ordinary-capacity addition exceeded `u128`.
238    ArithmeticOverflow {
239        /// First overflowing component.
240        dimension: ResourceDimension,
241    },
242}
243
244/// Componentwise maximum required capacity across a finite successor plan.
245#[derive(Clone, Copy, Debug, PartialEq, Eq)]
246pub struct RequiredCapacityPlan {
247    maximum: WideResourceVector,
248}
249
250impl RequiredCapacityPlan {
251    /// Takes the componentwise maximum across every reachable successor node.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`RequiredCapacityPlanError::EmptySuccessorSet`] for an empty
256    /// plan, which cannot prove closure coverage.
257    pub fn from_successors(
258        successors: &[WideResourceVector],
259    ) -> Result<Self, RequiredCapacityPlanError> {
260        let Some(first) = successors.first().copied() else {
261            return Err(RequiredCapacityPlanError::EmptySuccessorSet);
262        };
263        let maximum = successors.iter().skip(1).fold(first, |maximum, state| {
264            WideResourceVector::new(
265                maximum.entries.max(state.entries),
266                maximum.bytes.max(state.bytes),
267            )
268        });
269        Ok(Self { maximum })
270    }
271
272    /// Derives an ordinary caller's required `B' + Q + K` vector.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`RequiredCapacityPlanError::ArithmeticOverflow`] for the first
277    /// component whose canonical additions cannot fit `u128`.
278    pub fn ordinary(
279        resulting_baseline: WideResourceVector,
280        mandatory_bound: ResourceVector,
281        full_recovery_claim: ResourceVector,
282    ) -> Result<Self, RequiredCapacityPlanError> {
283        let Some(entries_with_q) = resulting_baseline
284            .entries
285            .checked_add(u128::from(mandatory_bound.entries))
286        else {
287            return Err(RequiredCapacityPlanError::ArithmeticOverflow {
288                dimension: ResourceDimension::Entries,
289            });
290        };
291        let Some(entries) = entries_with_q.checked_add(u128::from(full_recovery_claim.entries))
292        else {
293            return Err(RequiredCapacityPlanError::ArithmeticOverflow {
294                dimension: ResourceDimension::Entries,
295            });
296        };
297        let Some(bytes_with_q) = resulting_baseline
298            .bytes
299            .checked_add(u128::from(mandatory_bound.bytes))
300        else {
301            return Err(RequiredCapacityPlanError::ArithmeticOverflow {
302                dimension: ResourceDimension::Bytes,
303            });
304        };
305        let Some(bytes) = bytes_with_q.checked_add(u128::from(full_recovery_claim.bytes)) else {
306            return Err(RequiredCapacityPlanError::ArithmeticOverflow {
307                dimension: ResourceDimension::Bytes,
308            });
309        };
310        Ok(Self {
311            maximum: WideResourceVector::new(entries, bytes),
312        })
313    }
314
315    /// Returns the simulated componentwise maximum.
316    #[must_use]
317    pub const fn maximum(self) -> WideResourceVector {
318        self.maximum
319    }
320}
321
322/// Successful stage-7 proof that no recovery fence applies.
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324pub struct RecoveryFencePermit {
325    accounting: ClosureAccounting,
326}
327
328impl RecoveryFencePermit {
329    /// Returns the exact unchanged accounting checked at stage 7.
330    #[must_use]
331    pub const fn accounting(self) -> ClosureAccounting {
332        self.accounting
333    }
334}
335
336/// Stage-7 recovery-fence decision.
337#[derive(Clone, Debug, PartialEq, Eq)]
338#[cfg_attr(
339    target_pointer_width = "32",
340    expect(
341        clippy::large_enum_variant,
342        reason = "the refusal is boxed already; on a 32-bit target its box is 4 bytes, which \
343                  puts the eligible permit just over the lint's 200-byte line. The decision is \
344                  returned once per admission and moved once, so boxing the eligible arm would \
345                  pay an allocation on every admission to shrink a value nothing stores"
346    )
347)]
348pub enum RecoveryFenceDecision {
349    /// No detached-edge or second-quartet fence applies.
350    Eligible(RecoveryFencePermit),
351    /// Exact unchanged-prestate closure refusal.
352    Respond(Box<MarkerClosureCapacityExceeded>),
353}
354
355/// Applies the stage-7 recovery-fence predicate before numeric admission.
356#[must_use]
357pub fn check_recovery_fence(
358    request: &ClosureCheckedEnvelope,
359    accounting: ClosureAccounting,
360    recovery_fence: bool,
361) -> RecoveryFenceDecision {
362    if recovery_fence {
363        return RecoveryFenceDecision::Respond(Box::new(closure_refusal(
364            request.clone(),
365            accounting,
366            0,
367            ClosureRefusalReason::RecoveryFence,
368        )));
369    }
370    RecoveryFenceDecision::Eligible(RecoveryFencePermit { accounting })
371}
372
373/// Successful remaining closure gate.
374#[derive(Clone, Copy, Debug, PartialEq, Eq)]
375pub struct RemainingClosurePermit {
376    accounting: ClosureAccounting,
377    required_capacity: RequiredCapacityPlan,
378    delta_cycles: u64,
379}
380
381impl RemainingClosurePermit {
382    /// Returns the unchanged prestate accounting.
383    #[must_use]
384    pub const fn accounting(self) -> ClosureAccounting {
385        self.accounting
386    }
387
388    /// Returns the checked componentwise successor maximum.
389    #[must_use]
390    pub const fn required_capacity(self) -> RequiredCapacityPlan {
391        self.required_capacity
392    }
393
394    /// Returns the exact charged plan cycles.
395    #[must_use]
396    pub const fn delta_cycles(self) -> u64 {
397        self.delta_cycles
398    }
399}
400
401/// Stage-12 closure decision after observer admission.
402#[derive(Clone, Debug, PartialEq, Eq)]
403pub enum RemainingClosureDecision {
404    /// Delivered-marker, churn, and componentwise capacity checks passed.
405    Eligible(Box<RemainingClosurePermit>),
406    /// Exact first remaining closure refusal.
407    Respond(Box<MarkerClosureCapacityExceeded>),
408}
409
410/// Applies the remaining closure order: delivered marker, churn, then capacity.
411///
412/// Entries precede bytes and equality passes. A delivered-marker refusal uses
413/// `delta_cycles=0`; churn and capacity serialize the exact proposed charge.
414#[must_use]
415pub fn check_remaining_closure(
416    request: &ClosureCheckedEnvelope,
417    accounting: ClosureAccounting,
418    delivered_marker_awaiting_ack: bool,
419    delta_cycles: u64,
420    required_capacity: RequiredCapacityPlan,
421) -> RemainingClosureDecision {
422    if delivered_marker_awaiting_ack {
423        return RemainingClosureDecision::Respond(Box::new(closure_refusal(
424            request.clone(),
425            accounting,
426            0,
427            ClosureRefusalReason::DeliveredMarkerAwaitingAck,
428        )));
429    }
430    let resulting_cycles = u128::from(accounting.episode_churn_used) + u128::from(delta_cycles);
431    if resulting_cycles > u128::from(accounting.episode_churn_limit) {
432        return RemainingClosureDecision::Respond(Box::new(closure_refusal(
433            request.clone(),
434            accounting,
435            delta_cycles,
436            ClosureRefusalReason::EpisodeChurnLimit,
437        )));
438    }
439    let maximum = required_capacity.maximum;
440    if maximum.entries > u128::from(accounting.configured_cap.entries) {
441        return RemainingClosureDecision::Respond(Box::new(closure_refusal(
442            request.clone(),
443            accounting,
444            delta_cycles,
445            ClosureRefusalReason::Capacity(ClosureCapacityReason {
446                dimension: ResourceDimension::Entries,
447                required: maximum.entries,
448                limit: u128::from(accounting.configured_cap.entries),
449            }),
450        )));
451    }
452    if maximum.bytes > u128::from(accounting.configured_cap.bytes) {
453        return RemainingClosureDecision::Respond(Box::new(closure_refusal(
454            request.clone(),
455            accounting,
456            delta_cycles,
457            ClosureRefusalReason::Capacity(ClosureCapacityReason {
458                dimension: ResourceDimension::Bytes,
459                required: maximum.bytes,
460                limit: u128::from(accounting.configured_cap.bytes),
461            }),
462        )));
463    }
464    RemainingClosureDecision::Eligible(Box::new(RemainingClosurePermit {
465        accounting,
466        required_capacity,
467        delta_cycles,
468    }))
469}
470
471fn closure_refusal(
472    request: ClosureCheckedEnvelope,
473    accounting: ClosureAccounting,
474    delta_cycles: u64,
475    reason: ClosureRefusalReason,
476) -> MarkerClosureCapacityExceeded {
477    MarkerClosureCapacityExceeded {
478        request,
479        snapshot: accounting.snapshot(delta_cycles),
480        reason,
481    }
482}
483
484fn closure_state_wire(state: ClosureState) -> (ResourceVector, RepaymentEdge) {
485    match state {
486        ClosureState::Clear => (ResourceVector::default(), RepaymentEdge::None),
487        ClosureState::Owed { debt, edge } => {
488            let debt = debt.value();
489            let wire_debt = ResourceVector::new(
490                u64::try_from(debt.entries).unwrap_or(u64::MAX),
491                u64::try_from(debt.bytes).unwrap_or(u64::MAX),
492            );
493            (wire_debt, stored_edge_wire(edge))
494        }
495    }
496}
497
498const fn stored_edge_wire(edge: StoredEdge) -> RepaymentEdge {
499    match edge {
500        StoredEdge::ObserverProjection(edge) => RepaymentEdge::ObserverProjection {
501            through_seq: edge.through_seq(),
502        },
503        StoredEdge::PhysicalCompaction(edge) => RepaymentEdge::PhysicalCompaction {
504            from_floor: edge.from_floor(),
505            through_seq: edge.through_seq(),
506        },
507        StoredEdge::MarkerDelivery(edge) => RepaymentEdge::MarkerDelivery {
508            participant_id: edge.participant_id(),
509            binding_epoch: edge.binding_epoch(),
510            marker_delivery_seq: edge.marker_delivery_seq(),
511        },
512        StoredEdge::ParticipantCursorProgress(edge) => {
513            RepaymentEdge::ParticipantCursorProgress(ParticipantCursorProgressEdge {
514                participant_id: edge.participant_id(),
515                binding_epoch: edge.binding_epoch(),
516                through_seq: edge.through_seq(),
517                marker_delivery_seq: edge.marker_delivery_seq(),
518            })
519        }
520        StoredEdge::DetachedCredentialRecovery(edge) => RepaymentEdge::DetachedCredentialRecovery {
521            participant_id: edge.participant_id(),
522            marker_delivery_seq: edge.marker_delivery_seq(),
523            prior_binding_epoch: edge.prior_binding_epoch(),
524        },
525        StoredEdge::DetachedMarkerRelease(edge) => RepaymentEdge::DetachedMarkerRelease {
526            participant_id: edge.participant_id(),
527            marker_delivery_seq: edge.marker_delivery_seq(),
528            last_dead_binding_epoch: edge.last_dead_binding_epoch(),
529        },
530        StoredEdge::DetachedCursorRelease(edge) => RepaymentEdge::DetachedCursorRelease {
531            participant_id: edge.participant_id(),
532            last_dead_binding_epoch: edge.last_dead_binding_epoch(),
533        },
534    }
535}