Skip to main content

liminal_protocol/lifecycle/
enrollment.rs

1use crate::outcome::CandidatePhase;
2use crate::wire::{
3    AttachSecret, BindingEpoch, ConversationId, DeliverySeq, EnrollBound, EnrollmentRequest,
4    ParticipantId, ParticipantIndex, TransactionOrder,
5};
6
7use super::{ActiveBinding, AdmissionOrder, BindingState, EnrollmentFingerprint, LiveMember};
8
9/// Consuming allocator proof for one permanent participant reservation slot.
10///
11/// Implementations are supplied by the serialized identity allocator; the
12/// proof's existence attests that the returned slot was reserved exactly once.
13pub trait ParticipantSlotAllocatorProof {
14    /// Conversation whose identity allocator produced the slot.
15    fn conversation_id(&self) -> ConversationId;
16
17    /// Permanent participant index, also the participant id in protocol v1.
18    fn participant_index(&self) -> ParticipantIndex;
19
20    /// Configured half-open identity limit `I` used by the allocation.
21    fn identity_limit(&self) -> u64;
22}
23
24/// Opaque, checked participant-slot allocation consumed by enrollment.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct AllocatedParticipantSlot<P> {
27    allocator_proof: P,
28    conversation_id: ConversationId,
29    participant_id: ParticipantId,
30    identity_limit: u64,
31}
32
33/// Invalid participant-slot allocation proof.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum ParticipantSlotAllocationError {
36    /// Allocated index is outside the half-open `0..<I` domain.
37    IdentityLimit,
38}
39
40impl<P: ParticipantSlotAllocatorProof> AllocatedParticipantSlot<P> {
41    /// Validates and binds one consuming-layer allocator proof.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`ParticipantSlotAllocationError::IdentityLimit`] when the
46    /// allocator reports the exhausted sentinel or an out-of-range index.
47    pub fn from_allocator(allocator_proof: P) -> Result<Self, ParticipantSlotAllocationError> {
48        let participant_id = allocator_proof.participant_index();
49        let identity_limit = allocator_proof.identity_limit();
50        if participant_id >= identity_limit {
51            return Err(ParticipantSlotAllocationError::IdentityLimit);
52        }
53        Ok(Self {
54            conversation_id: allocator_proof.conversation_id(),
55            participant_id,
56            identity_limit,
57            allocator_proof,
58        })
59    }
60
61    /// Returns the allocator-bound conversation.
62    #[must_use]
63    pub const fn conversation_id(&self) -> ConversationId {
64        self.conversation_id
65    }
66
67    /// Returns the permanent participant id/index.
68    #[must_use]
69    pub const fn participant_id(&self) -> ParticipantId {
70        self.participant_id
71    }
72
73    /// Returns the checked half-open identity domain used by the allocator.
74    #[must_use]
75    pub const fn identity_limit(&self) -> u64 {
76        self.identity_limit
77    }
78}
79
80/// Assigned ordering and delivery position of one `Attached` lifecycle record.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub struct AttachedRecordPosition {
83    transaction_order: TransactionOrder,
84    delivery_seq: DeliverySeq,
85}
86
87impl AttachedRecordPosition {
88    /// Creates the committed position allocated by the conversation lane.
89    #[must_use]
90    pub const fn new(transaction_order: TransactionOrder, delivery_seq: DeliverySeq) -> Self {
91        Self {
92            transaction_order,
93            delivery_seq,
94        }
95    }
96
97    /// Returns the assigned transaction-order major.
98    #[must_use]
99    pub const fn transaction_order(self) -> TransactionOrder {
100        self.transaction_order
101    }
102
103    /// Returns the assigned delivery sequence.
104    #[must_use]
105    pub const fn delivery_seq(self) -> DeliverySeq {
106        self.delivery_seq
107    }
108}
109
110/// Exact committed `Attached` lifecycle fact.
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub struct AttachedLifecycleRecord {
113    participant_id: ParticipantId,
114    conversation_id: ConversationId,
115    binding_epoch: BindingEpoch,
116    admission_order: AdmissionOrder,
117    delivery_seq: DeliverySeq,
118}
119
120impl AttachedLifecycleRecord {
121    pub(crate) const fn from_binding(
122        binding: ActiveBinding,
123        position: AttachedRecordPosition,
124    ) -> Self {
125        Self {
126            participant_id: binding.participant_id,
127            conversation_id: binding.conversation_id,
128            binding_epoch: binding.binding_epoch,
129            admission_order: AdmissionOrder::new(
130                position.transaction_order,
131                CandidatePhase::AttachLifecycle,
132                binding.participant_id,
133            ),
134            delivery_seq: position.delivery_seq,
135        }
136    }
137
138    /// Returns the permanent participant id/index.
139    #[must_use]
140    pub const fn participant_id(self) -> ParticipantId {
141        self.participant_id
142    }
143
144    /// Returns the owning conversation.
145    #[must_use]
146    pub const fn conversation_id(self) -> ConversationId {
147        self.conversation_id
148    }
149
150    /// Returns the newly attached binding epoch.
151    #[must_use]
152    pub const fn binding_epoch(self) -> BindingEpoch {
153        self.binding_epoch
154    }
155
156    /// Returns the canonical phase-2 admission order.
157    #[must_use]
158    pub const fn admission_order(self) -> AdmissionOrder {
159        self.admission_order
160    }
161
162    /// Returns the committed record sequence.
163    #[must_use]
164    pub const fn delivery_seq(self) -> DeliverySeq {
165        self.delivery_seq
166    }
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170enum BindingOriginKind {
171    Unfenced,
172    Recovered {
173        marker_delivery_seq: DeliverySeq,
174        prior_binding_epoch: BindingEpoch,
175    },
176}
177
178/// Opaque durable origin of one current or last authoritative binding.
179///
180/// Enrollment and verified ordinary/superseding attach emit the unfenced form;
181/// verified marker-fenced attach emits the recovered form. The discriminator
182/// and every field are private so stored raw values cannot relabel a recovered
183/// binding as ordinary authority. Cold restoration replays the protocol commit
184/// that originally emitted this capsule, then passes it to total conversation
185/// restore.
186///
187/// ```compile_fail
188/// use liminal_protocol::lifecycle::BindingOrigin;
189///
190/// fn relabel_recovered_as_unfenced(origin: BindingOrigin) -> BindingOrigin {
191///     BindingOrigin::unfenced(origin.attached())
192/// }
193/// ```
194///
195/// Producer commits also keep the capsule behind the protocol replay boundary:
196///
197/// ```compile_fail
198/// use liminal_protocol::lifecycle::AttachCommit;
199///
200/// fn extract_attach<F, V>(commit: &AttachCommit<F, V>) {
201///     let _ = commit.binding_origin();
202/// }
203/// ```
204///
205/// ```compile_fail
206/// use liminal_protocol::lifecycle::EnrollmentCommit;
207///
208/// fn extract_enrollment<F>(commit: &EnrollmentCommit<F>) {
209///     let _ = commit.binding_origin();
210/// }
211/// ```
212#[derive(Clone, Copy, Debug, PartialEq, Eq)]
213pub struct BindingOrigin {
214    attached: AttachedLifecycleRecord,
215    kind: BindingOriginKind,
216}
217
218impl BindingOrigin {
219    pub(crate) const fn unfenced(attached: AttachedLifecycleRecord) -> Self {
220        Self {
221            attached,
222            kind: BindingOriginKind::Unfenced,
223        }
224    }
225
226    pub(crate) const fn recovered(
227        attached: AttachedLifecycleRecord,
228        marker_delivery_seq: DeliverySeq,
229        prior_binding_epoch: BindingEpoch,
230    ) -> Self {
231        Self {
232            attached,
233            kind: BindingOriginKind::Recovered {
234                marker_delivery_seq,
235                prior_binding_epoch,
236            },
237        }
238    }
239
240    pub(crate) const fn attached(self) -> AttachedLifecycleRecord {
241        self.attached
242    }
243
244    pub(crate) const fn conversation_id(self) -> ConversationId {
245        self.attached.conversation_id()
246    }
247
248    pub(crate) const fn participant_id(self) -> ParticipantId {
249        self.attached.participant_id()
250    }
251
252    pub(crate) const fn binding_epoch(self) -> BindingEpoch {
253        self.attached.binding_epoch()
254    }
255
256    pub(crate) const fn is_unfenced(self) -> bool {
257        matches!(self.kind, BindingOriginKind::Unfenced)
258    }
259
260    pub(crate) const fn recovered_marker(self) -> Option<(DeliverySeq, BindingEpoch)> {
261        match self.kind {
262            BindingOriginKind::Unfenced => None,
263            BindingOriginKind::Recovered {
264                marker_delivery_seq,
265                prior_binding_epoch,
266            } => Some((marker_delivery_seq, prior_binding_epoch)),
267        }
268    }
269}
270
271/// Values allocated by one successful enrollment transaction.
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct EnrollmentCommitParameters<F, P> {
274    /// Consumed permanent identity reservation.
275    pub allocated_slot: AllocatedParticipantSlot<P>,
276    /// Newly minted generation-one attach secret.
277    pub attach_secret: AttachSecret,
278    /// New generation-one binding epoch.
279    pub origin_binding_epoch: BindingEpoch,
280    /// Assigned `Attached` record position.
281    pub attached_position: AttachedRecordPosition,
282    /// Live receipt deadline.
283    pub receipt_expires_at: u128,
284    /// Provenance deadline.
285    pub provenance_expires_at: u128,
286    /// Permanent enrollment-token mapping fingerprint.
287    pub enrollment_fingerprint: EnrollmentFingerprint<F>,
288}
289
290/// Complete atomic enrollment result.
291#[derive(Clone, Debug, PartialEq, Eq)]
292pub struct EnrollmentCommit<F> {
293    /// New durable generation-one membership.
294    pub member: LiveMember<F>,
295    /// New authoritative binding slot.
296    pub binding_state: BindingState,
297    /// Exact committed `Attached` lifecycle fact.
298    pub attached: AttachedLifecycleRecord,
299    /// Exact canonical enrollment receipt.
300    pub outcome: EnrollBound,
301    binding_origin: BindingOrigin,
302}
303
304impl<F> EnrollmentCommit<F> {
305    /// Returns the opaque unfenced binding origin emitted by enrollment.
306    #[must_use]
307    #[allow(
308        dead_code,
309        reason = "the crate-owned event replay boundary persists this producer-emitted origin"
310    )]
311    pub(crate) const fn binding_origin(&self) -> BindingOrigin {
312        self.binding_origin
313    }
314}
315
316/// Failure while committing a previously allocated enrollment.
317#[derive(Clone, Copy, Debug, PartialEq, Eq)]
318pub enum EnrollmentCommitError {
319    /// Request and allocated slot name different conversations.
320    Conversation,
321    /// Allocated binding epoch is not generation one.
322    BindingGeneration,
323    /// Canonical wire receipt rejected the supplied epoch.
324    ReceiptInvariant,
325}
326
327/// Atomically creates membership, binding, `Attached`, and canonical receipt.
328///
329/// # Errors
330///
331/// Returns [`EnrollmentCommitError`] when the consumed allocator proof and
332/// request disagree or the result binding is not generation one.
333pub fn commit_enrollment<F, P>(
334    request: &EnrollmentRequest,
335    parameters: EnrollmentCommitParameters<F, P>,
336) -> Result<EnrollmentCommit<F>, EnrollmentCommitError> {
337    if request.conversation_id != parameters.allocated_slot.conversation_id {
338        return Err(EnrollmentCommitError::Conversation);
339    }
340    if parameters.origin_binding_epoch.capability_generation != crate::wire::Generation::ONE {
341        return Err(EnrollmentCommitError::BindingGeneration);
342    }
343    let participant_id = parameters.allocated_slot.participant_id;
344    let binding = ActiveBinding {
345        participant_id,
346        conversation_id: request.conversation_id,
347        binding_epoch: parameters.origin_binding_epoch,
348    };
349    let attached = AttachedLifecycleRecord::from_binding(binding, parameters.attached_position);
350    let Some(outcome) = EnrollBound::new(
351        request.conversation_id,
352        request.enrollment_token,
353        participant_id,
354        parameters.attach_secret,
355        parameters.origin_binding_epoch,
356        parameters.receipt_expires_at,
357        parameters.provenance_expires_at,
358    ) else {
359        return Err(EnrollmentCommitError::ReceiptInvariant);
360    };
361    let member = LiveMember::from_enrollment(
362        participant_id,
363        request.conversation_id,
364        parameters.attach_secret,
365        parameters.enrollment_fingerprint,
366    );
367    let _consumed_allocator_proof = parameters.allocated_slot.allocator_proof;
368    Ok(EnrollmentCommit {
369        member,
370        binding_state: BindingState::Bound(binding),
371        attached,
372        outcome,
373        binding_origin: BindingOrigin::unfenced(attached),
374    })
375}