Skip to main content

liminal_protocol/lifecycle/
enrollment_closure.rs

1//! Protocol-owned closure projection for enrollment.
2//!
3//! This module first closes the frozen Case 25 base producer: enrollment into
4//! an empty, clear conversation. The projection derives every resulting scalar
5//! from validated durable ledgers and configuration. It deliberately exposes no
6//! constructor for general successor coverage; nonempty histories must not pass
7//! a caller-supplied marker count, capacity maximum, edge, or recovery quartet.
8
9use alloc::vec::Vec;
10
11use crate::algebra::{
12    BaselineError, MandatoryCapacity, ResourceDimension, ResourceVector, WideResourceVector,
13    mandatory_capacity, retained_baseline, zero_debt_admission,
14};
15use crate::wire::{BindingEpoch, ClosureCheckedEnvelope, Generation, ParticipantIndex};
16
17use super::admission::{
18    OrderAdmissionError, OrderClaims, OrderHigh, OrderLedger, ResultingOrderClaims,
19    ResultingSequenceState, SequenceAdmissionError, SequenceClaims, SequenceLedger,
20};
21use super::{
22    ClosureAccounting, ClosureAccountingError, ClosureDebt, ClosureState, ObserverProjection,
23    RemainingClosureDecision, RequiredCapacityPlan, StoredEdge, check_remaining_closure,
24};
25
26/// Maximum accepted frozen churn limit width.
27const MAX_CHURN_LIMIT: u64 = u32::MAX as u64;
28
29/// Durable state and signed configuration required by initial enrollment.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct InitialEnrollmentClosureInput {
32    accounting: ClosureAccounting,
33    identity_slots: u64,
34    mandatory_bound: ResourceVector,
35    recovery_claim: ResourceVector,
36    marker_max: ResourceVector,
37    attached_charge: ResourceVector,
38    participant_index: ParticipantIndex,
39    binding_epoch: BindingEpoch,
40    order: OrderLedger,
41    sequence: SequenceLedger,
42    physical_floor: u128,
43    observer_progress: u64,
44}
45
46impl InitialEnrollmentClosureInput {
47    /// Captures the persisted empty-conversation facts read by the projector.
48    ///
49    /// Validation is intentionally deferred to
50    /// [`project_initial_enrollment_closure`] so the caller cannot obtain any
51    /// authority merely by constructing this factual snapshot.
52    #[allow(clippy::too_many_arguments)]
53    #[must_use]
54    pub const fn new(
55        accounting: ClosureAccounting,
56        identity_slots: u64,
57        mandatory_bound: ResourceVector,
58        recovery_claim: ResourceVector,
59        marker_max: ResourceVector,
60        attached_charge: ResourceVector,
61        participant_index: ParticipantIndex,
62        binding_epoch: BindingEpoch,
63        order: OrderLedger,
64        sequence: SequenceLedger,
65        physical_floor: u128,
66        observer_progress: u64,
67    ) -> Self {
68        Self {
69            accounting,
70            identity_slots,
71            mandatory_bound,
72            recovery_claim,
73            marker_max,
74            attached_charge,
75            participant_index,
76            binding_epoch,
77            order,
78            sequence,
79            physical_floor,
80            observer_progress,
81        }
82    }
83
84    /// Replaces the factual binding epoch before projection validation.
85    #[must_use]
86    pub const fn with_binding_epoch(mut self, binding_epoch: BindingEpoch) -> Self {
87        self.binding_epoch = binding_epoch;
88        self
89    }
90
91    /// Replaces the factual encoded `Attached` charge before validation.
92    #[must_use]
93    pub const fn with_attached_charge(mut self, attached_charge: ResourceVector) -> Self {
94        self.attached_charge = attached_charge;
95        self
96    }
97}
98
99/// Whether one enrollment projection endows the sole recovery quartet.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum RecoveryQuartetStatus {
102    /// No anchored delivered-marker recovery branch exists.
103    None,
104    /// The complete coupled `RS`/`RT`/`RO`/`RA` quartet is endowed once.
105    Endowed,
106}
107
108/// Planned marker owned by a newly overtaken enrollment participant.
109///
110/// The initial-enrollment base case always returns an empty set. The type is
111/// retained in the projection boundary so later nonempty fixed-point coverage
112/// cannot regress to a raw marker count.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub struct PlannedEnrollmentMarker {
115    participant_index: ParticipantIndex,
116    planned_delivery_seq: u64,
117}
118
119impl PlannedEnrollmentMarker {
120    /// Returns the permanent participant index owning the marker credit.
121    #[must_use]
122    pub const fn participant_index(self) -> ParticipantIndex {
123        self.participant_index
124    }
125
126    /// Returns the exact pre-owned sequence value of the marker candidate.
127    #[must_use]
128    pub const fn planned_delivery_seq(self) -> u64 {
129        self.planned_delivery_seq
130    }
131}
132
133/// Fully derived, persistable initial-enrollment closure projection.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct InitialEnrollmentClosureProjection {
136    current_accounting: ClosureAccounting,
137    resulting_accounting: ClosureAccounting,
138    resulting_retained_charge: ResourceVector,
139    resulting_floor: u128,
140    resulting_baseline: WideResourceVector,
141    remaining_recovery_claim: ResourceVector,
142    capacity: MandatoryCapacity,
143    required_capacity: RequiredCapacityPlan,
144    recovery_quartet: RecoveryQuartetStatus,
145    new_markers: Vec<PlannedEnrollmentMarker>,
146    order: OrderLedger,
147    sequence: SequenceLedger,
148    participant_index: ParticipantIndex,
149    identity_slots: u64,
150    binding_epoch: BindingEpoch,
151}
152
153impl InitialEnrollmentClosureProjection {
154    /// Returns the exact closure accounting to persist atomically on success.
155    #[must_use]
156    pub const fn resulting_closure_accounting(&self) -> ClosureAccounting {
157        self.resulting_accounting
158    }
159
160    /// Returns the exact typed clear-or-owed closure state.
161    #[must_use]
162    pub const fn resulting_closure_state(&self) -> ClosureState {
163        self.resulting_accounting.state()
164    }
165
166    /// Returns the retained closure charge `S'` after the `Attached` append.
167    #[must_use]
168    pub const fn resulting_retained_charge(&self) -> ResourceVector {
169        self.resulting_retained_charge
170    }
171
172    /// Returns the reproducible physical floor `F'`.
173    #[must_use]
174    pub const fn resulting_floor(&self) -> u128 {
175        self.resulting_floor
176    }
177
178    /// Returns the exact resulting retained baseline `B'`.
179    #[must_use]
180    pub const fn resulting_baseline(&self) -> WideResourceVector {
181        self.resulting_baseline
182    }
183
184    /// Returns the exact resulting closure debt.
185    #[must_use]
186    pub const fn debt(&self) -> WideResourceVector {
187        self.capacity.debt
188    }
189
190    /// Returns `K_remaining'`, which is full `K` while debt is owed and zero
191    /// when the projection remains clear.
192    #[must_use]
193    pub const fn remaining_recovery_claim(&self) -> ResourceVector {
194        self.remaining_recovery_claim
195    }
196
197    /// Returns whether the projection endowed the coupled recovery quartet.
198    #[must_use]
199    pub const fn recovery_quartet(&self) -> RecoveryQuartetStatus {
200        self.recovery_quartet
201    }
202
203    /// Returns the exact newly created marker candidates.
204    #[must_use]
205    pub fn new_marker_candidates(&self) -> &[PlannedEnrollmentMarker] {
206        &self.new_markers
207    }
208
209    /// Returns the componentwise maximum across the complete base successor
210    /// coverage used by stage 12.
211    #[must_use]
212    pub const fn required_capacity(&self) -> RequiredCapacityPlan {
213        self.required_capacity
214    }
215
216    /// Returns the permanent participant index reserved by this projection.
217    #[must_use]
218    pub const fn participant_index(&self) -> ParticipantIndex {
219        self.participant_index
220    }
221
222    /// Returns the validated half-open identity domain used by the projection.
223    #[must_use]
224    pub const fn identity_slots(&self) -> u64 {
225        self.identity_slots
226    }
227
228    /// Returns the generation-one binding epoch used by the successor proof.
229    #[must_use]
230    pub const fn binding_epoch(&self) -> BindingEpoch {
231        self.binding_epoch
232    }
233
234    /// Returns the unchanged current order ledger consumed by stage 9.
235    #[must_use]
236    pub const fn current_order(&self) -> OrderLedger {
237        self.order
238    }
239
240    /// Returns the hard observer progress checked by stage 11.
241    #[must_use]
242    pub const fn observer_progress(&self) -> u64 {
243        0
244    }
245
246    /// Produces the sealed enrollment order claims, including a quartet only
247    /// when the fixed-point coverage derived one.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`OrderAdmissionError`] for claim overflow or an attempted
252    /// second recovery quartet.
253    pub(crate) fn plan_order(&self) -> Result<ResultingOrderClaims, OrderAdmissionError> {
254        self.order.plan_enrollment_with_recovery_quartet(matches!(
255            self.recovery_quartet,
256            RecoveryQuartetStatus::Endowed
257        ))
258    }
259
260    /// Produces the sealed resulting enrollment sequence state.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`SequenceAdmissionError`] for counter/claim overflow or an
265    /// attempted second recovery quartet.
266    pub(crate) fn plan_sequence(&self) -> Result<ResultingSequenceState, SequenceAdmissionError> {
267        let marker_count = u64::try_from(self.new_markers.len()).map_err(|_| {
268            SequenceAdmissionError::MarkerClaimOverflow {
269                markers: self.sequence.claims().markers(),
270                new_markers: u64::MAX,
271            }
272        })?;
273        self.sequence.plan_enrollment_with_recovery_quartet(
274            marker_count,
275            matches!(self.recovery_quartet, RecoveryQuartetStatus::Endowed),
276        )
277    }
278
279    /// Runs the frozen stage-12 selector against the unchanged prestate.
280    ///
281    /// Order, sequence, and observer admission must run before the caller
282    /// exposes this decision.
283    #[must_use]
284    pub fn remaining_closure_decision(
285        &self,
286        request: &ClosureCheckedEnvelope,
287    ) -> RemainingClosureDecision {
288        check_remaining_closure(
289            request,
290            self.current_accounting,
291            false,
292            0,
293            self.required_capacity,
294        )
295    }
296}
297
298/// Malformed durable/configuration input for initial enrollment projection.
299#[derive(Clone, Debug, PartialEq, Eq)]
300pub enum InitialEnrollmentClosureError {
301    /// Initial enrollment requires canonical clear closure state.
302    ClosureNotClear,
303    /// Clear state retained edge-owned claims or recovery occupancy.
304    ClearOwnsEdgeResources,
305    /// A clear episode must have reset its churn counter.
306    ClearChurnNotReset {
307        /// Invalid durable churn count.
308        used: u64,
309    },
310    /// Frozen `J` is outside `2..=u32::MAX`.
311    ChurnLimit {
312        /// Invalid configured value.
313        configured: u64,
314    },
315    /// Initial conversation must have at least one identity slot.
316    ZeroIdentitySlots,
317    /// Frozen configuration requires `Q == K` componentwise.
318    RecoveryClaimDiffersFromMandatoryBound,
319    /// Reserved participant index is outside `0..<I`.
320    ParticipantIndexOutsideIdentityLimit {
321        /// Presented index.
322        participant_index: ParticipantIndex,
323        /// Configured half-open limit.
324        identity_slots: u64,
325    },
326    /// The empty conversation's monotone allocator must emit index zero.
327    InitialParticipantIndexNotZero {
328        /// Invalid first allocated index.
329        participant_index: ParticipantIndex,
330    },
331    /// Initial enrollment must bind generation one.
332    BindingGeneration {
333        /// Invalid binding generation.
334        generation: Generation,
335    },
336    /// Initial order ledger is not empty and claim-free.
337    NonemptyOrderLedger,
338    /// Initial sequence ledger is not the zero watermark with no claims.
339    NonemptySequenceLedger,
340    /// Empty log uses exactly `F=1` and `o=0`.
341    InitialFloorOrObserver {
342        /// Presented physical floor.
343        physical_floor: u128,
344        /// Presented hard observer progress.
345        observer_progress: u64,
346    },
347    /// Empty durable marker state must have no credits or anchors.
348    NonemptyMarkerState {
349        /// Durable marker credits.
350        credits: u64,
351        /// Durable marker anchors.
352        anchors: u64,
353    },
354    /// Durable empty baseline differs from `I * marker_max`.
355    BaselineMismatch {
356        /// Baseline derived by the protocol.
357        derived: WideResourceVector,
358        /// Baseline carried by durable accounting.
359        durable: WideResourceVector,
360    },
361    /// The signed startup zero-debt envelope is invalid.
362    StartupEnvelope {
363        /// First failing resource dimension.
364        dimension: ResourceDimension,
365    },
366    /// Attached charge is outside the mandatory transaction bound.
367    AttachedChargeExceedsMandatoryBound {
368        /// First failing resource dimension.
369        dimension: ResourceDimension,
370    },
371    /// One `Attached` lifecycle record has exactly one entry.
372    AttachedEntryCharge {
373        /// Invalid entry charge.
374        actual: u64,
375    },
376    /// Resulting retained charge overflowed its durable u64 representation.
377    RetainedChargeOverflow {
378        /// First overflowing component.
379        dimension: ResourceDimension,
380    },
381    /// No legal mandatory enrollment poststate exists at the initial floor.
382    MandatoryCapacity,
383    /// Resulting closure accounting violated its structural invariant.
384    ResultingAccounting(ClosureAccountingError),
385    /// Baseline derivation rejected an impossible credit count.
386    Baseline(BaselineError),
387}
388
389/// Projects the complete initial-enrollment closure transition.
390///
391/// The frozen Case 25 state has no prior rows, members, marker owners, or
392/// candidates. Consequently cursor zero is not overtaken at `F'=1`, the marker
393/// fixed point is empty, and no anchored DCR branch exists. Positive debt stores
394/// `ObserverProjection { through_seq: 1 }`, retains full `K`, and owns no
395/// `RS`/`RT`/`RO`/`RA` quartet.
396///
397/// # Errors
398///
399/// Returns [`InitialEnrollmentClosureError`] instead of normalizing any
400/// malformed durable ordering, accounting, configuration, or initial ledger.
401pub fn project_initial_enrollment_closure(
402    input: InitialEnrollmentClosureInput,
403) -> Result<InitialEnrollmentClosureProjection, InitialEnrollmentClosureError> {
404    validate_initial_input(&input)?;
405
406    let initial_baseline = retained_baseline(
407        ResourceVector::default(),
408        input.identity_slots,
409        0,
410        input.marker_max,
411    )
412    .map_err(InitialEnrollmentClosureError::Baseline)?;
413    let resulting_retained_charge = input.attached_charge;
414    let resulting_baseline = retained_baseline(
415        resulting_retained_charge,
416        input.identity_slots,
417        0,
418        input.marker_max,
419    )
420    .map_err(InitialEnrollmentClosureError::Baseline)?;
421    let capacity = mandatory_capacity(
422        resulting_baseline,
423        input.mandatory_bound,
424        input.recovery_claim,
425        input.accounting.configured_cap(),
426    );
427    if !capacity.is_legal() {
428        return Err(InitialEnrollmentClosureError::MandatoryCapacity);
429    }
430
431    let (state, remaining_recovery_claim, edge_sequence_claims, edge_order_claims) =
432        ClosureDebt::new(capacity.debt).map_or_else(
433            || (ClosureState::Clear, ResourceVector::default(), 0, 0),
434            |debt| {
435                (
436                    ClosureState::Owed {
437                        debt,
438                        edge: StoredEdge::ObserverProjection(ObserverProjection::new(1)),
439                    },
440                    input.recovery_claim,
441                    0,
442                    0,
443                )
444            },
445        );
446
447    let resulting_accounting = ClosureAccounting::try_new(
448        state,
449        0,
450        0,
451        edge_sequence_claims,
452        edge_order_claims,
453        remaining_recovery_claim,
454        resulting_baseline,
455        input.accounting.configured_cap(),
456        0,
457        input.accounting.episode_churn_limit(),
458    )
459    .map_err(InitialEnrollmentClosureError::ResultingAccounting)?;
460
461    let immediate_required = if capacity.debt.is_zero() {
462        checked_sum(
463            resulting_baseline,
464            input.mandatory_bound,
465            input.recovery_claim,
466        )?
467    } else {
468        checked_sum(
469            resulting_baseline,
470            ResourceVector::default(),
471            input.recovery_claim,
472        )?
473    };
474    let clear_successor_required = checked_sum(
475        initial_baseline,
476        input.mandatory_bound,
477        input.recovery_claim,
478    )?;
479    let required_capacity =
480        RequiredCapacityPlan::from_successors(&[immediate_required, clear_successor_required])
481            .map_err(|_| InitialEnrollmentClosureError::MandatoryCapacity)?;
482
483    Ok(InitialEnrollmentClosureProjection {
484        current_accounting: input.accounting,
485        resulting_accounting,
486        resulting_retained_charge,
487        resulting_floor: 1,
488        resulting_baseline,
489        remaining_recovery_claim,
490        capacity,
491        required_capacity,
492        recovery_quartet: RecoveryQuartetStatus::None,
493        new_markers: Vec::new(),
494        order: input.order,
495        sequence: input.sequence,
496        participant_index: input.participant_index,
497        identity_slots: input.identity_slots,
498        binding_epoch: input.binding_epoch,
499    })
500}
501
502fn validate_initial_input(
503    input: &InitialEnrollmentClosureInput,
504) -> Result<(), InitialEnrollmentClosureError> {
505    if input.accounting.state() != ClosureState::Clear {
506        return Err(InitialEnrollmentClosureError::ClosureNotClear);
507    }
508    if input.accounting.edge_sequence_claims() != 0
509        || input.accounting.edge_order_position_claims() != 0
510        || input.accounting.edge_k_remaining() != ResourceVector::default()
511    {
512        return Err(InitialEnrollmentClosureError::ClearOwnsEdgeResources);
513    }
514    if input.accounting.episode_churn_used() != 0 {
515        return Err(InitialEnrollmentClosureError::ClearChurnNotReset {
516            used: input.accounting.episode_churn_used(),
517        });
518    }
519    let churn_limit = input.accounting.episode_churn_limit();
520    if !(2..=MAX_CHURN_LIMIT).contains(&churn_limit) {
521        return Err(InitialEnrollmentClosureError::ChurnLimit {
522            configured: churn_limit,
523        });
524    }
525    if input.identity_slots == 0 {
526        return Err(InitialEnrollmentClosureError::ZeroIdentitySlots);
527    }
528    if input.mandatory_bound != input.recovery_claim {
529        return Err(InitialEnrollmentClosureError::RecoveryClaimDiffersFromMandatoryBound);
530    }
531    if input.participant_index >= input.identity_slots {
532        return Err(
533            InitialEnrollmentClosureError::ParticipantIndexOutsideIdentityLimit {
534                participant_index: input.participant_index,
535                identity_slots: input.identity_slots,
536            },
537        );
538    }
539    if input.participant_index != 0 {
540        return Err(
541            InitialEnrollmentClosureError::InitialParticipantIndexNotZero {
542                participant_index: input.participant_index,
543            },
544        );
545    }
546    if input.binding_epoch.capability_generation != Generation::ONE {
547        return Err(InitialEnrollmentClosureError::BindingGeneration {
548            generation: input.binding_epoch.capability_generation,
549        });
550    }
551    validate_initial_ledgers_and_floor(input)?;
552    validate_initial_capacity(input)
553}
554
555fn validate_initial_ledgers_and_floor(
556    input: &InitialEnrollmentClosureInput,
557) -> Result<(), InitialEnrollmentClosureError> {
558    if input.order.high() != OrderHigh::Empty || input.order.claims() != OrderClaims::default() {
559        return Err(InitialEnrollmentClosureError::NonemptyOrderLedger);
560    }
561    if input.sequence.high_watermark() != 0 || input.sequence.claims() != SequenceClaims::default()
562    {
563        return Err(InitialEnrollmentClosureError::NonemptySequenceLedger);
564    }
565    if input.physical_floor != 1 || input.observer_progress != 0 {
566        return Err(InitialEnrollmentClosureError::InitialFloorOrObserver {
567            physical_floor: input.physical_floor,
568            observer_progress: input.observer_progress,
569        });
570    }
571    if input.accounting.marker_capacity_credits() != 0 || input.accounting.marker_anchors() != 0 {
572        return Err(InitialEnrollmentClosureError::NonemptyMarkerState {
573            credits: input.accounting.marker_capacity_credits(),
574            anchors: input.accounting.marker_anchors(),
575        });
576    }
577    Ok(())
578}
579
580fn validate_initial_capacity(
581    input: &InitialEnrollmentClosureInput,
582) -> Result<(), InitialEnrollmentClosureError> {
583    let derived_baseline = retained_baseline(
584        ResourceVector::default(),
585        input.identity_slots,
586        0,
587        input.marker_max,
588    )
589    .map_err(InitialEnrollmentClosureError::Baseline)?;
590    if derived_baseline != input.accounting.baseline() {
591        return Err(InitialEnrollmentClosureError::BaselineMismatch {
592            derived: derived_baseline,
593            durable: input.accounting.baseline(),
594        });
595    }
596    if let Some(dimension) = startup_envelope_failure(
597        derived_baseline,
598        input.mandatory_bound,
599        input.recovery_claim,
600        input.accounting.configured_cap(),
601    ) {
602        return Err(InitialEnrollmentClosureError::StartupEnvelope { dimension });
603    }
604    if input.attached_charge.entries != 1 {
605        return Err(InitialEnrollmentClosureError::AttachedEntryCharge {
606            actual: input.attached_charge.entries,
607        });
608    }
609    if input.attached_charge.entries > input.mandatory_bound.entries {
610        return Err(
611            InitialEnrollmentClosureError::AttachedChargeExceedsMandatoryBound {
612                dimension: ResourceDimension::Entries,
613            },
614        );
615    }
616    if input.attached_charge.bytes > input.mandatory_bound.bytes {
617        return Err(
618            InitialEnrollmentClosureError::AttachedChargeExceedsMandatoryBound {
619                dimension: ResourceDimension::Bytes,
620            },
621        );
622    }
623    Ok(())
624}
625
626const fn startup_envelope_failure(
627    baseline: WideResourceVector,
628    mandatory_bound: ResourceVector,
629    recovery_claim: ResourceVector,
630    configured_cap: ResourceVector,
631) -> Option<ResourceDimension> {
632    if zero_debt_admission(baseline, mandatory_bound, recovery_claim, configured_cap) {
633        None
634    } else {
635        crate::algebra::zero_debt_capacity_failure(
636            baseline,
637            mandatory_bound,
638            recovery_claim,
639            configured_cap,
640        )
641    }
642}
643
644fn checked_sum(
645    baseline: WideResourceVector,
646    middle: ResourceVector,
647    last: ResourceVector,
648) -> Result<WideResourceVector, InitialEnrollmentClosureError> {
649    let Some(entries) = baseline
650        .entries
651        .checked_add(u128::from(middle.entries))
652        .and_then(|value| value.checked_add(u128::from(last.entries)))
653    else {
654        return Err(InitialEnrollmentClosureError::RetainedChargeOverflow {
655            dimension: ResourceDimension::Entries,
656        });
657    };
658    let Some(bytes) = baseline
659        .bytes
660        .checked_add(u128::from(middle.bytes))
661        .and_then(|value| value.checked_add(u128::from(last.bytes)))
662    else {
663        return Err(InitialEnrollmentClosureError::RetainedChargeOverflow {
664            dimension: ResourceDimension::Bytes,
665        });
666    };
667    Ok(WideResourceVector::new(entries, bytes))
668}