Skip to main content

liminal_protocol/lifecycle/admission/
capacity.rs

1use core::num::NonZeroU64;
2
3use crate::wire::{
4    AttachEnvelope, CredentialAttachRequest, CredentialAttachResponse, EnrollmentEnvelope,
5    EnrollmentRequest, EnrollmentResponse, IdentityCapacityExceeded, IdentityCapacityScope,
6    ParticipantId,
7};
8
9/// Invalid persisted occupancy for one signed nonzero capacity.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum CapacityCounterInvariantError {
12    /// Protocol capacity limits are nonzero.
13    ZeroLimit,
14    /// Persisted occupancy is greater than its signed limit.
15    OccupiedExceedsLimit {
16        /// Persisted occupancy.
17        occupied: u64,
18        /// Signed capacity limit.
19        limit: u64,
20    },
21}
22
23/// Validated occupancy bounded by one nonzero signed limit.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct CapacityCounter {
26    limit: NonZeroU64,
27    occupied: u64,
28}
29
30impl CapacityCounter {
31    /// Restores one counter only when its limit is nonzero and occupancy fits.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`CapacityCounterInvariantError::ZeroLimit`] for a zero limit or
36    /// [`CapacityCounterInvariantError::OccupiedExceedsLimit`] when persisted
37    /// occupancy is outside the inclusive `0..=limit` domain.
38    pub const fn try_new(limit: u64, occupied: u64) -> Result<Self, CapacityCounterInvariantError> {
39        let Some(limit) = NonZeroU64::new(limit) else {
40            return Err(CapacityCounterInvariantError::ZeroLimit);
41        };
42        if occupied > limit.get() {
43            return Err(CapacityCounterInvariantError::OccupiedExceedsLimit {
44                occupied,
45                limit: limit.get(),
46            });
47        }
48        Ok(Self { limit, occupied })
49    }
50
51    /// Returns the signed nonzero limit.
52    #[must_use]
53    pub const fn limit(self) -> u64 {
54        self.limit.get()
55    }
56
57    /// Returns current validated occupancy.
58    #[must_use]
59    pub const fn occupied(self) -> u64 {
60        self.occupied
61    }
62
63    /// Returns whether another row would exceed the signed limit.
64    #[must_use]
65    pub const fn is_full(self) -> bool {
66        self.occupied == self.limit.get()
67    }
68
69    const fn incremented(self) -> Option<Self> {
70        if self.is_full() {
71            return None;
72        }
73        Some(Self {
74            limit: self.limit,
75            occupied: self.occupied + 1,
76        })
77    }
78}
79
80/// Invalid restored occupancy for a participant that has not yet been minted.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum FreshParticipantCapacityCounterInvariantError {
83    /// The underlying nonzero bounded counter is invalid.
84    Capacity(CapacityCounterInvariantError),
85    /// A not-yet-minted participant cannot already own receipt state.
86    Nonempty {
87        /// Invalid restored per-participant occupancy.
88        occupied: u64,
89    },
90}
91
92/// Provably empty, nonzero per-participant capacity for fresh enrollment.
93///
94/// This type removes the unreachable enrollment refusal arms while still
95/// forcing the successful transaction to reserve both new participant rows.
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub struct FreshParticipantCapacityCounter {
98    counter: CapacityCounter,
99}
100
101impl FreshParticipantCapacityCounter {
102    /// Restores a fresh-participant counter only at occupancy zero.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`FreshParticipantCapacityCounterInvariantError::Capacity`] for
107    /// an invalid base counter or
108    /// [`FreshParticipantCapacityCounterInvariantError::Nonempty`] when a
109    /// not-yet-minted participant already has a row.
110    pub const fn try_new(
111        limit: u64,
112        occupied: u64,
113    ) -> Result<Self, FreshParticipantCapacityCounterInvariantError> {
114        let counter = match CapacityCounter::try_new(limit, occupied) {
115            Ok(counter) => counter,
116            Err(error) => {
117                return Err(FreshParticipantCapacityCounterInvariantError::Capacity(
118                    error,
119                ));
120            }
121        };
122        if occupied != 0 {
123            return Err(FreshParticipantCapacityCounterInvariantError::Nonempty { occupied });
124        }
125        Ok(Self { counter })
126    }
127
128    /// Returns the signed nonzero per-participant limit.
129    #[must_use]
130    pub const fn limit(self) -> u64 {
131        self.counter.limit()
132    }
133
134    /// Returns the type-proven zero occupancy.
135    #[must_use]
136    pub const fn occupied(self) -> u64 {
137        self.counter.occupied()
138    }
139
140    const fn reserved(self) -> CapacityCounter {
141        CapacityCounter {
142            limit: self.counter.limit,
143            occupied: 1,
144        }
145    }
146
147    /// The window as enrollment leaves it when the operation fills NOTHING:
148    /// nonzero size, zero occupancy.
149    ///
150    /// Board #37: enrollment mints a receipt body but retains no provenance
151    /// fingerprint — nothing has yet proven possession of the secret that
152    /// receipt minted — so the provenance window is reserved and still empty.
153    const fn unfilled(self) -> CapacityCounter {
154        self.counter
155    }
156}
157
158/// Whether a per-participant window entry landed into headroom or had to
159/// displace the window's oldest member to make room.
160///
161/// Both arms LAND the new entry. The window size is a bound on retention, not
162/// a refusal threshold: per-participant pressure is self-inflicted (your own
163/// churn displaces your own oldest fingerprint), so the number bounds memory
164/// without ever refusing an honest arrival.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum ParticipantWindowAdmission {
167    /// The window had headroom; occupancy rose by one and nothing was lost.
168    Landed,
169    /// The window was exactly full; its OLDEST in-window member is displaced
170    /// so the new entry can land, and occupancy stays exactly at the bound.
171    Displaced,
172}
173
174/// One per-participant window's admission paired with the occupancy it leaves.
175///
176/// There is no refusal arm by construction. `resulting` never exceeds the
177/// signed window size, and under [`ParticipantWindowAdmission::Displaced`] it
178/// is exactly that size — the bound holds exactly, in both arms.
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180pub struct ParticipantWindowCommit {
181    admission: ParticipantWindowAdmission,
182    resulting: CapacityCounter,
183}
184
185impl ParticipantWindowCommit {
186    /// Returns whether landing displaced the window's oldest member.
187    #[must_use]
188    pub const fn admission(self) -> ParticipantWindowAdmission {
189        self.admission
190    }
191
192    /// Returns whether this admission displaced an older member — the fact
193    /// the server's visibility surface counts.
194    #[must_use]
195    pub const fn displaced(self) -> bool {
196        matches!(self.admission, ParticipantWindowAdmission::Displaced)
197    }
198
199    /// Returns the post-admission occupancy, always within the window.
200    #[must_use]
201    pub const fn resulting(self) -> CapacityCounter {
202        self.resulting
203    }
204}
205
206/// Admits one entry into a per-participant retention window.
207///
208/// A window with headroom takes the entry and grows by one. A full window
209/// displaces its oldest member and stays exactly full. The entry ALWAYS
210/// lands: this selector has no refusal arm, which is the whole of Tom's
211/// governing sentence — *no configured number refuses an honest arrival* —
212/// expressed in the type.
213#[must_use]
214pub const fn select_participant_window(current: CapacityCounter) -> ParticipantWindowCommit {
215    match current.incremented() {
216        Some(resulting) => ParticipantWindowCommit {
217            admission: ParticipantWindowAdmission::Landed,
218            resulting,
219        },
220        None => ParticipantWindowCommit {
221            admission: ParticipantWindowAdmission::Displaced,
222            resulting: current,
223        },
224    }
225}
226
227/// Whether a semantic request's conversation already owns a connection slot.
228#[derive(Clone, Copy, Debug, PartialEq, Eq)]
229pub enum ConnectionConversationTracking {
230    /// The conversation is already counted and consumes no additional slot.
231    AlreadyTracked,
232    /// The conversation needs its first connection-local slot.
233    Untracked,
234}
235
236/// Atomic successful result of semantic connection-capacity admission.
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub struct ConnectionConversationCapacityCommit {
239    resulting: CapacityCounter,
240    newly_tracked: bool,
241}
242
243impl ConnectionConversationCapacityCommit {
244    /// Returns the complete post-operation connection occupancy.
245    #[must_use]
246    pub const fn resulting(self) -> CapacityCounter {
247        self.resulting
248    }
249
250    /// Returns whether the operation must install a new conversation slot.
251    #[must_use]
252    pub const fn newly_tracked(self) -> bool {
253        self.newly_tracked
254    }
255}
256
257/// Stage-6 semantic connection-capacity result.
258///
259/// The refusal arm carries only the request-independent capacity fact; the
260/// invoking operation mints its request-bound `0x0102` wire outcome from its
261/// own exact envelope plus this signed limit, so the triggering envelope is
262/// never duplicated through this shared selector.
263#[derive(Clone, Debug, PartialEq, Eq)]
264pub enum SemanticConnectionCapacityDecision {
265    /// Existing or newly reserved conversation capacity may commit.
266    Commit(ConnectionConversationCapacityCommit),
267    /// The untracked conversation would exceed the signed limit.
268    Respond {
269        /// Signed connection-conversation limit that is full.
270        limit: u64,
271    },
272}
273
274/// Applies semantic connection-conversation capacity before participant mutation.
275///
276/// An already tracked conversation succeeds without incrementing the counter,
277/// even when capacity is full. An untracked conversation either returns the
278/// complete incremented counter or the signed limit for the caller's exact
279/// request-bound `0x0102` wire outcome.
280#[must_use]
281pub const fn select_semantic_connection_capacity(
282    tracking: ConnectionConversationTracking,
283    current: CapacityCounter,
284) -> SemanticConnectionCapacityDecision {
285    match tracking {
286        ConnectionConversationTracking::AlreadyTracked => {
287            SemanticConnectionCapacityDecision::Commit(ConnectionConversationCapacityCommit {
288                resulting: current,
289                newly_tracked: false,
290            })
291        }
292        ConnectionConversationTracking::Untracked => {
293            let Some(resulting) = current.incremented() else {
294                return SemanticConnectionCapacityDecision::Respond {
295                    limit: current.limit(),
296                };
297            };
298            SemanticConnectionCapacityDecision::Commit(ConnectionConversationCapacityCommit {
299                resulting,
300                newly_tracked: true,
301            })
302        }
303    }
304}
305
306/// Current participant occupancy of one connection/conversation binding slot.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum BindingSlotOccupancy {
309    /// No participant currently occupies the slot.
310    Empty,
311    /// One participant currently occupies the slot.
312    Occupied {
313        /// Occupying participant, used only for same-participant rotation.
314        participant_id: ParticipantId,
315    },
316}
317
318/// Stage-6 participant binding-slot result, bound to the requesting
319/// operation's response authority.
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub enum BindingSlotDecision<R> {
322    /// The binding operation may continue.
323    Available,
324    /// Exact request-bound binding-slot refusal.
325    Respond(R),
326}
327
328/// Selects enrollment binding-slot occupancy without revealing its occupant.
329#[must_use]
330pub const fn select_enrollment_binding_slot(
331    request: &EnrollmentRequest,
332    occupancy: BindingSlotOccupancy,
333) -> BindingSlotDecision<EnrollmentResponse> {
334    match occupancy {
335        BindingSlotOccupancy::Empty => BindingSlotDecision::Available,
336        BindingSlotOccupancy::Occupied { .. } => BindingSlotDecision::Respond(
337            EnrollmentResponse::connection_conversation_binding_occupied(&enrollment_envelope(
338                request,
339            )),
340        ),
341    }
342}
343
344/// Selects credential-attach binding occupancy, permitting only an empty slot
345/// or rotation of the same presented participant.
346#[must_use]
347pub const fn select_credential_attach_binding_slot(
348    request: &CredentialAttachRequest,
349    occupancy: BindingSlotOccupancy,
350) -> BindingSlotDecision<CredentialAttachResponse> {
351    match occupancy {
352        BindingSlotOccupancy::Empty => BindingSlotDecision::Available,
353        BindingSlotOccupancy::Occupied { participant_id }
354            if participant_id == request.participant_id =>
355        {
356            BindingSlotDecision::Available
357        }
358        BindingSlotOccupancy::Occupied { .. } => BindingSlotDecision::Respond(
359            CredentialAttachResponse::connection_conversation_binding_occupied(&attach_envelope(
360                request,
361            )),
362        ),
363    }
364}
365
366/// The stage-8 counters one fresh enrollment decides against.
367///
368/// Only the two IDENTITY counters can refuse. The two per-participant window
369/// counters use [`FreshParticipantCapacityCounter`], proving their occupancy
370/// is zero and their sizes nonzero before identity mint — which is exactly
371/// what makes a fresh participant's first receipt land without a decision.
372///
373/// # Lane p0-39: the shared pools are gone from this decision
374///
375/// `LiveReceiptServer`, `ProvenanceServer`, and `ProvenanceConversation` are
376/// no longer admission gates in any scope. They are where an honest THIRD
377/// PARTY would meet a number someone else's churn consumed, and no configured
378/// refusal is tolerable there; their retention is bounded by the TTL windows
379/// alone, with a reporting tripwire in place of a wall. The wire scopes remain
380/// assigned and defined — they are simply never emitted from these paths.
381#[derive(Clone, Copy, Debug, PartialEq, Eq)]
382pub struct EnrollmentCapacityCounters {
383    identity_server: CapacityCounter,
384    identity_conversation: CapacityCounter,
385    live_receipt_participant: FreshParticipantCapacityCounter,
386    provenance_participant: FreshParticipantCapacityCounter,
387}
388
389impl EnrollmentCapacityCounters {
390    /// Creates the complete reachable enrollment counter snapshot.
391    #[must_use]
392    pub const fn new(
393        identity_server: CapacityCounter,
394        identity_conversation: CapacityCounter,
395        live_receipt_participant: FreshParticipantCapacityCounter,
396        provenance_participant: FreshParticipantCapacityCounter,
397    ) -> Self {
398        Self {
399            identity_server,
400            identity_conversation,
401            live_receipt_participant,
402            provenance_participant,
403        }
404    }
405
406    /// Returns server-wide identity occupancy.
407    #[must_use]
408    pub const fn identity_server(self) -> CapacityCounter {
409        self.identity_server
410    }
411
412    /// Returns conversation identity occupancy.
413    #[must_use]
414    pub const fn identity_conversation(self) -> CapacityCounter {
415        self.identity_conversation
416    }
417
418    /// Returns the provably empty participant live-receipt window.
419    #[must_use]
420    pub const fn live_receipt_participant(self) -> FreshParticipantCapacityCounter {
421        self.live_receipt_participant
422    }
423
424    /// Returns the provably empty participant provenance window.
425    #[must_use]
426    pub const fn provenance_participant(self) -> FreshParticipantCapacityCounter {
427        self.provenance_participant
428    }
429}
430
431/// The post-enrollment identity counters and per-participant window occupancy.
432#[derive(Clone, Copy, Debug, PartialEq, Eq)]
433pub struct ResultingEnrollmentCapacityCounters {
434    identity_server: CapacityCounter,
435    identity_conversation: CapacityCounter,
436    live_receipt_participant: CapacityCounter,
437    provenance_participant: CapacityCounter,
438}
439
440impl ResultingEnrollmentCapacityCounters {
441    /// Returns server-wide identity occupancy.
442    #[must_use]
443    pub const fn identity_server(self) -> CapacityCounter {
444        self.identity_server
445    }
446
447    /// Returns conversation identity occupancy.
448    #[must_use]
449    pub const fn identity_conversation(self) -> CapacityCounter {
450        self.identity_conversation
451    }
452
453    /// Returns the newly minted participant's live-receipt window occupancy.
454    #[must_use]
455    pub const fn live_receipt_participant(self) -> CapacityCounter {
456        self.live_receipt_participant
457    }
458
459    /// Returns the newly minted participant's provenance window occupancy.
460    ///
461    /// Board #37: this is zero at mint — nothing has proven possession of the
462    /// secret the enrollment receipt just minted, so no fingerprint is
463    /// retained yet. The window is reserved, not filled.
464    #[must_use]
465    pub const fn provenance_participant(self) -> CapacityCounter {
466        self.provenance_participant
467    }
468}
469
470/// Atomic successful enrollment capacity reservation.
471#[derive(Clone, Copy, Debug, PartialEq, Eq)]
472pub struct EnrollmentCapacityCommit {
473    resulting: ResultingEnrollmentCapacityCounters,
474}
475
476impl EnrollmentCapacityCommit {
477    /// Returns every incremented enrollment counter as one commit value.
478    #[must_use]
479    pub const fn resulting(self) -> ResultingEnrollmentCapacityCounters {
480        self.resulting
481    }
482}
483
484/// Exhaustive stage-8 enrollment runtime-capacity result.
485#[derive(Clone, Debug, PartialEq, Eq)]
486pub enum EnrollmentCapacityDecision {
487    /// Both identity reservations and both window reservations may commit.
488    Commit(EnrollmentCapacityCommit),
489    /// Exact first-full IDENTITY scope, bound to enrollment. The receipt
490    /// scopes have no refusal arm on this path at all.
491    Respond(EnrollmentResponse),
492}
493
494/// Applies the enrollment runtime-capacity order atomically.
495///
496/// The order is identity Server then identity Conversation — the complete
497/// refusable set. A refusal exposes only the first full scope; success
498/// carries the post-increment identity counters and the fresh participant's
499/// two reserved windows.
500#[must_use]
501pub const fn select_enrollment_capacity(
502    request: &EnrollmentRequest,
503    current: EnrollmentCapacityCounters,
504) -> EnrollmentCapacityDecision {
505    let Some(identity_server) = current.identity_server.incremented() else {
506        return enrollment_identity_refusal(
507            request,
508            IdentityCapacityScope::Server,
509            current.identity_server,
510        );
511    };
512    let Some(identity_conversation) = current.identity_conversation.incremented() else {
513        return enrollment_identity_refusal(
514            request,
515            IdentityCapacityScope::Conversation,
516            current.identity_conversation,
517        );
518    };
519
520    EnrollmentCapacityDecision::Commit(EnrollmentCapacityCommit {
521        resulting: ResultingEnrollmentCapacityCounters {
522            identity_server,
523            identity_conversation,
524            live_receipt_participant: current.live_receipt_participant.reserved(),
525            provenance_participant: current.provenance_participant.unfilled(),
526        },
527    })
528}
529
530/// The two per-participant retention windows credential attach admits into.
531///
532/// The three shared scopes this snapshot used to carry are gone: they no
533/// longer gate anything, so passing them here would be an unread number
534/// pretending to be a decision input.
535#[derive(Clone, Copy, Debug, PartialEq, Eq)]
536pub struct CredentialAttachCapacityCounters {
537    live_receipt_participant: CapacityCounter,
538    provenance_participant: CapacityCounter,
539}
540
541impl CredentialAttachCapacityCounters {
542    /// Creates the complete credential-attach window snapshot.
543    #[must_use]
544    pub const fn new(
545        live_receipt_participant: CapacityCounter,
546        provenance_participant: CapacityCounter,
547    ) -> Self {
548        Self {
549            live_receipt_participant,
550            provenance_participant,
551        }
552    }
553
554    /// Returns participant live-receipt window occupancy.
555    #[must_use]
556    pub const fn live_receipt_participant(self) -> CapacityCounter {
557        self.live_receipt_participant
558    }
559
560    /// Returns participant provenance window occupancy.
561    #[must_use]
562    pub const fn provenance_participant(self) -> CapacityCounter {
563        self.provenance_participant
564    }
565}
566
567/// Atomic credential-attach window admission.
568///
569/// There is no refusal counterpart. Every credential attach that reaches
570/// stage 8 admits; the only outcome carried here is whether each window had
571/// to displace its oldest member to make room.
572#[derive(Clone, Copy, Debug, PartialEq, Eq)]
573pub struct CredentialAttachCapacityCommit {
574    live_receipt_participant: ParticipantWindowCommit,
575    provenance_participant: ParticipantWindowCommit,
576}
577
578impl CredentialAttachCapacityCommit {
579    /// Returns the participant live-receipt window's admission.
580    #[must_use]
581    pub const fn live_receipt_participant(self) -> ParticipantWindowCommit {
582        self.live_receipt_participant
583    }
584
585    /// Returns the participant provenance window's admission.
586    #[must_use]
587    pub const fn provenance_participant(self) -> ParticipantWindowCommit {
588        self.provenance_participant
589    }
590}
591
592/// Admits one credential attach into both per-participant windows.
593///
594/// This selector is TOTAL: it returns a commit for every input, because the
595/// (N+1)th honest fingerprint of a participant always lands. Whichever window
596/// was full displaces its own oldest member, and the caller applies exactly
597/// that displacement to the ledger and the slot from one shared plan.
598#[must_use]
599pub const fn select_credential_attach_capacity(
600    current: CredentialAttachCapacityCounters,
601) -> CredentialAttachCapacityCommit {
602    CredentialAttachCapacityCommit {
603        live_receipt_participant: select_participant_window(current.live_receipt_participant),
604        provenance_participant: select_participant_window(current.provenance_participant),
605    }
606}
607
608const fn enrollment_identity_refusal(
609    request: &EnrollmentRequest,
610    scope: IdentityCapacityScope,
611    counter: CapacityCounter,
612) -> EnrollmentCapacityDecision {
613    EnrollmentCapacityDecision::Respond(EnrollmentResponse::identity_capacity_exceeded(
614        IdentityCapacityExceeded {
615            request: enrollment_envelope(request),
616            scope,
617            limit: counter.limit(),
618            occupied: counter.occupied(),
619        },
620    ))
621}
622
623const fn enrollment_envelope(request: &EnrollmentRequest) -> EnrollmentEnvelope {
624    EnrollmentEnvelope {
625        conversation_id: request.conversation_id,
626        enrollment_token: request.enrollment_token,
627    }
628}
629
630const fn attach_envelope(request: &CredentialAttachRequest) -> AttachEnvelope {
631    AttachEnvelope {
632        conversation_id: request.conversation_id,
633        participant_id: request.participant_id,
634        capability_generation: request.capability_generation,
635        attach_attempt_token: request.attach_attempt_token,
636        accept_marker_delivery_seq: request.accept_marker_delivery_seq,
637    }
638}