Skip to main content

liminal_protocol/lifecycle/operations/
binding_fate.rs

1use alloc::boxed::Box;
2
3use crate::{
4    algebra::{floor_transition, marker_clamped_floor},
5    wire::DeliverySeq,
6};
7
8use super::{LiveFrontierError, LiveFrontierOwner, live_frontier::BindingFateOwnerPlan};
9use crate::lifecycle::{
10    CommittedDiedTerminal, Event, FrontierBinding, ObserverProgressProjection, OrdinaryBindingFate,
11    RecoveredBindingFate, SealedBindingFateIntent, SealedBindingFateToken,
12};
13
14/// Closed terminal input accepted by protocol-owned binding-fate measurement.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum BindingFateTerminal {
17    /// Ordinary fate consumes the exact committed Died terminal.
18    Ordinary(CommittedDiedTerminal),
19    /// Recovered fate after committed Died receives no Died terminal.
20    Recovered,
21    /// Recovered fate after pending Died preserves exact finalizer authority.
22    RecoveredAndReserveFinalizer,
23}
24
25/// Protocol-produced binding fate after measuring the post-release floor.
26#[derive(Debug, PartialEq, Eq)]
27pub enum MeasuredBindingFate {
28    /// Ordinary no-marker fate with exact Died provenance.
29    Ordinary(OrdinaryBindingFate),
30    /// Fenced recovered fate with no Died terminal input.
31    Recovered(RecoveredBindingFate),
32}
33
34impl MeasuredBindingFate {
35    /// Returns the measured floor carried by either closed fate class.
36    #[must_use]
37    pub const fn resulting_floor(&self) -> DeliverySeq {
38        match self {
39            Self::Ordinary(fate) => fate.resulting_floor(),
40            Self::Recovered(fate) => fate.resulting_floor(),
41        }
42    }
43
44    /// Projects the protocol-measured floor for observer routing.
45    #[must_use]
46    pub const fn observer_progress_projection(&self) -> ObserverProgressProjection {
47        match self {
48            Self::Ordinary(fate) => fate.observer_progress_projection(),
49            Self::Recovered(fate) => fate.observer_progress_projection(),
50        }
51    }
52}
53
54/// Successful protocol measurement retaining the coupled frontier owner.
55#[derive(Debug, PartialEq, Eq)]
56pub struct PreparedBindingFate {
57    owner: LiveFrontierOwner,
58    fate: MeasuredBindingFate,
59    event: Event,
60}
61
62impl PreparedBindingFate {
63    /// Borrows the measured fate.
64    #[must_use]
65    pub const fn fate(&self) -> &MeasuredBindingFate {
66        &self.fate
67    }
68
69    /// Returns the internally minted binding-fate event.
70    #[must_use]
71    pub const fn event(&self) -> Event {
72        self.event
73    }
74
75    /// Consumes the prepared transition into the measured next owner and fate.
76    #[must_use]
77    pub fn into_parts(self) -> (LiveFrontierOwner, MeasuredBindingFate, Event) {
78        (self.owner, self.fate, self.event)
79    }
80}
81
82/// Floor authority measured before a Pending-Died enclosing finalizer commits.
83#[derive(Debug, PartialEq, Eq)]
84pub struct PendingDiedOrdinaryFinalizer {
85    resulting_floor: DeliverySeq,
86    authority: FinalizerAuthority,
87}
88
89#[derive(Debug, PartialEq, Eq)]
90struct FinalizerAuthority;
91
92/// Ordinary fate and unchanged owner prepared for an enclosing finalizer.
93#[derive(Debug, PartialEq, Eq)]
94pub struct PreparedPendingDiedOrdinaryFinalizer {
95    owner: LiveFrontierOwner,
96    fate: OrdinaryBindingFate,
97    finalizer: PendingDiedOrdinaryFinalizer,
98}
99
100impl PreparedPendingDiedOrdinaryFinalizer {
101    /// Returns the unchanged owner, measured fate, and one-use floor authority.
102    #[must_use]
103    pub fn into_parts(
104        self,
105    ) -> (
106        LiveFrontierOwner,
107        OrdinaryBindingFate,
108        PendingDiedOrdinaryFinalizer,
109    ) {
110        (self.owner, self.fate, self.finalizer)
111    }
112}
113
114impl LiveFrontierOwner {
115    /// Applies a measured Ordinary floor after its enclosing finalizer transition.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`LiveFrontierError`] if retained charges, the measured floor,
120    /// marker precedence, or closure accounting disagree with the post-finalizer owner.
121    pub fn complete_pending_died_ordinary_finalizer(
122        self,
123        finalizer: PendingDiedOrdinaryFinalizer,
124    ) -> Result<Self, LiveFrontierError> {
125        let PendingDiedOrdinaryFinalizer {
126            resulting_floor,
127            authority,
128        } = finalizer;
129        let FinalizerAuthority = authority;
130        self.install_finalized_binding_fate_floor(resulting_floor)
131    }
132}
133
134/// Typed reason protocol-owned binding-fate measurement refused.
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum BindingFateMeasurementError {
137    /// The sealed token has no unique ordinary or recovered authority.
138    Token,
139    /// The token names a different conversation.
140    Conversation,
141    /// The token's participant is absent from the coupled frontier.
142    Participant,
143    /// The token's binding epoch or cursor disagrees with the coupled frontier.
144    Binding,
145    /// Ordinary/recovered terminal input disagrees with the token class.
146    Terminal,
147    /// Hard observer progress exceeds the candidate high watermark.
148    ObserverProgress,
149    /// The measured checked floor is outside the delivery-sequence domain.
150    ResultingFloor,
151    /// The coupled frontier, retained charges, or closure baseline refused
152    /// installation, CARRYING the protocol's own cause.
153    ///
154    /// §3.3: the cause rides this carrier BY TYPE, because the park-versus-fatal
155    /// decision downstream is not allowed to read it back out of a formatted
156    /// message. The payload is free — both this enum and `LiveFrontierError`
157    /// are `Copy` (`live_frontier.rs:1047`), so the derives above survive it.
158    OwnerTransition(LiveFrontierError),
159}
160
161/// Refused measurement preserving every move-only input for serial retry.
162#[derive(Debug, PartialEq, Eq)]
163pub struct BindingFateMeasurementRefused {
164    owner: LiveFrontierOwner,
165    token: SealedBindingFateToken,
166    terminal: BindingFateTerminal,
167    error: BindingFateMeasurementError,
168}
169
170impl BindingFateMeasurementRefused {
171    /// Returns the typed refusal reason.
172    #[must_use]
173    pub const fn error(&self) -> BindingFateMeasurementError {
174        self.error
175    }
176
177    /// Recovers every unchanged input for a same-lock serial retry.
178    #[must_use]
179    pub fn into_parts(
180        self,
181    ) -> (
182        LiveFrontierOwner,
183        SealedBindingFateToken,
184        BindingFateTerminal,
185    ) {
186        (self.owner, self.token, self.terminal)
187    }
188}
189
190struct ValidatedBindingFateMeasurement {
191    participant_id: crate::wire::ParticipantId,
192    binding_epoch: crate::wire::BindingEpoch,
193    resulting_floor: DeliverySeq,
194    owner_plan: BindingFateOwnerPlan,
195}
196
197struct ValidatedBindingFateFloor {
198    participant_id: crate::wire::ParticipantId,
199    binding_epoch: crate::wire::BindingEpoch,
200    resulting_floor: DeliverySeq,
201}
202
203impl LiveFrontierOwner {
204    /// Consumes one sealed fate token after measuring its real post-release floor.
205    ///
206    /// The server supplies only hard observer progress and the closed terminal
207    /// class. The participant, binding epoch, current retained floor, candidate
208    /// high watermark, and remaining member cursors all come from protocol-owned
209    /// state. Recovered internally mints its event and accepts no terminal.
210    ///
211    /// # Errors
212    ///
213    /// Returns every input unchanged when authority, terminal class, observer
214    /// progress, or checked floor validation fails.
215    pub fn prepare_binding_fate(
216        self,
217        token: SealedBindingFateToken,
218        terminal: BindingFateTerminal,
219        hard_observer_progress: DeliverySeq,
220    ) -> Result<PreparedBindingFate, Box<BindingFateMeasurementRefused>> {
221        let measurement = match validate_binding_fate_measurement(
222            &self,
223            &token,
224            terminal,
225            hard_observer_progress,
226        ) {
227            Ok(measurement) => measurement,
228            Err(error) => return refusal(self, token, terminal, error),
229        };
230        let event = Event::binding_fate_observed(
231            measurement.participant_id,
232            measurement.binding_epoch,
233            measurement.resulting_floor,
234        );
235        let fate = match terminal {
236            BindingFateTerminal::Ordinary(terminal) => token
237                .ordinary_binding_fate(terminal, measurement.resulting_floor)
238                .map(MeasuredBindingFate::Ordinary),
239            BindingFateTerminal::Recovered | BindingFateTerminal::RecoveredAndReserveFinalizer => {
240                token
241                    .recovered_binding_fate_measured(measurement.resulting_floor)
242                    .map(MeasuredBindingFate::Recovered)
243            }
244        };
245        match fate {
246            Ok(fate) => {
247                let owner = self.install_binding_fate_transition(
248                    measurement.owner_plan,
249                    measurement.resulting_floor,
250                );
251                Ok(PreparedBindingFate { owner, fate, event })
252            }
253            Err(token) => Err(Box::new(BindingFateMeasurementRefused {
254                owner: self,
255                token: *token,
256                terminal,
257                error: BindingFateMeasurementError::Terminal,
258            })),
259        }
260    }
261
262    /// Measures Ordinary without installing its owner transition before an enclosing finalizer.
263    ///
264    /// # Errors
265    ///
266    /// Returns every move-only input unchanged when the token, terminal,
267    /// observer progress, measured floor, or pre-finalizer owner transition
268    /// is inconsistent.
269    pub fn prepare_pending_died_ordinary_finalizer(
270        self,
271        token: SealedBindingFateToken,
272        terminal: CommittedDiedTerminal,
273        hard_observer_progress: DeliverySeq,
274    ) -> Result<PreparedPendingDiedOrdinaryFinalizer, Box<BindingFateMeasurementRefused>> {
275        let terminal_input = BindingFateTerminal::Ordinary(terminal);
276        let measurement = match validate_binding_fate_measurement(
277            &self,
278            &token,
279            terminal_input,
280            hard_observer_progress,
281        ) {
282            Ok(measurement) => measurement,
283            Err(error) => {
284                return Err(Box::new(BindingFateMeasurementRefused {
285                    owner: self,
286                    token,
287                    terminal: terminal_input,
288                    error,
289                }));
290            }
291        };
292        let resulting_floor = measurement.resulting_floor;
293        match token.ordinary_binding_fate(terminal, resulting_floor) {
294            Ok(fate) => Ok(PreparedPendingDiedOrdinaryFinalizer {
295                owner: self,
296                fate,
297                finalizer: PendingDiedOrdinaryFinalizer {
298                    resulting_floor,
299                    authority: FinalizerAuthority,
300                },
301            }),
302            Err(token) => Err(Box::new(BindingFateMeasurementRefused {
303                owner: self,
304                token: *token,
305                terminal: terminal_input,
306                error: BindingFateMeasurementError::Terminal,
307            })),
308        }
309    }
310
311    /// Measures Ordinary after fenced proof minting consumed marker authority.
312    ///
313    /// The fenced attach transition itself owns the pending identity change, so
314    /// this preparation validates the token, terminal, cursor, and exact floor
315    /// but defers floor installation to the post-attach owner.
316    ///
317    /// # Errors
318    ///
319    /// Returns every move-only input unchanged when token authority, terminal,
320    /// observer progress, or checked floor measurement is inconsistent.
321    pub fn prepare_pending_died_ordinary_after_fenced_proof(
322        self,
323        token: SealedBindingFateToken,
324        terminal: CommittedDiedTerminal,
325        hard_observer_progress: DeliverySeq,
326    ) -> Result<PreparedPendingDiedOrdinaryFinalizer, Box<BindingFateMeasurementRefused>> {
327        let terminal_input = BindingFateTerminal::Ordinary(terminal);
328        let measurement = match validate_binding_fate_floor(
329            &self,
330            &token,
331            terminal_input,
332            hard_observer_progress,
333        ) {
334            Ok(measurement) => measurement,
335            Err(error) => {
336                return Err(Box::new(BindingFateMeasurementRefused {
337                    owner: self,
338                    token,
339                    terminal: terminal_input,
340                    error,
341                }));
342            }
343        };
344        let resulting_floor = measurement.resulting_floor;
345        match token.ordinary_binding_fate(terminal, resulting_floor) {
346            Ok(fate) => Ok(PreparedPendingDiedOrdinaryFinalizer {
347                owner: self,
348                fate,
349                finalizer: PendingDiedOrdinaryFinalizer {
350                    resulting_floor,
351                    authority: FinalizerAuthority,
352                },
353            }),
354            Err(token) => Err(Box::new(BindingFateMeasurementRefused {
355                owner: self,
356                token: *token,
357                terminal: terminal_input,
358                error: BindingFateMeasurementError::Terminal,
359            })),
360        }
361    }
362}
363
364fn validate_binding_fate_measurement(
365    owner: &LiveFrontierOwner,
366    token: &SealedBindingFateToken,
367    terminal: BindingFateTerminal,
368    hard_observer_progress: DeliverySeq,
369) -> Result<ValidatedBindingFateMeasurement, BindingFateMeasurementError> {
370    let floor = validate_binding_fate_floor(owner, token, terminal, hard_observer_progress)?;
371    let owner_plan = owner
372        .prepare_binding_fate_transition(
373            floor.participant_id,
374            floor.binding_epoch,
375            token
376                .measurement_context()
377                .ok_or(BindingFateMeasurementError::Token)?
378                .cursor,
379            floor.resulting_floor,
380            terminal == BindingFateTerminal::RecoveredAndReserveFinalizer,
381        )
382        .map_err(BindingFateMeasurementError::OwnerTransition)?;
383    Ok(ValidatedBindingFateMeasurement {
384        participant_id: floor.participant_id,
385        binding_epoch: floor.binding_epoch,
386        resulting_floor: floor.resulting_floor,
387        owner_plan,
388    })
389}
390
391fn validate_binding_fate_floor(
392    owner: &LiveFrontierOwner,
393    token: &SealedBindingFateToken,
394    terminal: BindingFateTerminal,
395    hard_observer_progress: DeliverySeq,
396) -> Result<ValidatedBindingFateFloor, BindingFateMeasurementError> {
397    let Some(context) = token.measurement_context() else {
398        return Err(BindingFateMeasurementError::Token);
399    };
400    if context.conversation_id != owner.frontiers().conversation_id() {
401        return Err(BindingFateMeasurementError::Conversation);
402    }
403    let Some(participant) = owner
404        .frontiers()
405        .active_identities()
406        .participants()
407        .iter()
408        .find(|participant| participant.participant_index() == context.participant_id)
409    else {
410        return Err(BindingFateMeasurementError::Participant);
411    };
412    if participant.cursor() != context.cursor
413        || participant.binding() != FrontierBinding::Bound(context.binding_epoch)
414            && participant.binding() != FrontierBinding::Detached(context.binding_epoch)
415    {
416        return Err(BindingFateMeasurementError::Binding);
417    }
418    let terminal_matches = match (token.intent(), terminal) {
419        (Some(SealedBindingFateIntent::Ordinary), BindingFateTerminal::Ordinary(died)) => {
420            died.conversation_id() == context.conversation_id
421                && died.participant_id() == context.participant_id
422                && died.binding_epoch() == context.binding_epoch
423        }
424        (
425            Some(SealedBindingFateIntent::Recovered { .. }),
426            BindingFateTerminal::Recovered | BindingFateTerminal::RecoveredAndReserveFinalizer,
427        ) => true,
428        _ => false,
429    };
430    if !terminal_matches {
431        return Err(BindingFateMeasurementError::Terminal);
432    }
433    let candidate_high_watermark = owner.frontiers().sequence().ledger().high_watermark();
434    if hard_observer_progress > candidate_high_watermark {
435        return Err(BindingFateMeasurementError::ObserverProgress);
436    }
437    let minimum_remaining_cursor = owner
438        .frontiers()
439        .active_identities()
440        .participants()
441        .iter()
442        .filter(|participant| participant.participant_index() != context.participant_id)
443        .map(|participant| participant.cursor())
444        .min();
445    let measured = floor_transition(
446        owner.frontiers().retained_floor(),
447        minimum_remaining_cursor,
448        candidate_high_watermark,
449        hard_observer_progress,
450        owner.frontiers().retained_floor(),
451    );
452    // §3.1 MARKERS PIN THE FLOOR (docs/design/F8-MARKER-POISON.md §3.1).
453    //
454    // `floor_transition` above takes no marker input of any kind -- its five
455    // arguments are the retained floor, the minimum remaining cursor, the
456    // candidate high watermark, hard observer progress, and the retained floor
457    // again. Downstream, the live-frontier transition REFUSES a floor that
458    // crosses a retained marker (`LiveFrontierError::Precedence`). Those were
459    // two halves of one invariant with neither clamping, so the computation
460    // could produce a floor the transition was obliged to reject: the incident
461    // in §1, where the refusal WAS collapsed to a bare `OwnerTransition` and
462    // the Died row carrying the intent WAS already durable by the time the
463    // measurement ran. Both halves of that clause describe the PRE-FIX path and
464    // nothing else. §3.3 now carries the cause by type (`OwnerTransition` takes
465    // a `LiveFrontierError`), and §3.2 split the live path so the measurement
466    // runs BEFORE the source append -- only the combined boot form still
467    // measures against a source that is already durable.
468    //
469    // The purpose of a retained marker is precisely that its record stays
470    // replayable until acked. Pinning is the marker's meaning; unsatisfiability
471    // was the bug. So the measured floor is LOWERED to the minimum retained
472    // marker-record sequence and can no longer cross one.
473    //
474    // NOT `cap_floor` (PRECEDENCE-CLAMP M2). `floor_transition`'s fifth argument
475    // is named `cap_floor` and is `max(base_result, cap_floor)` — a floor
476    // RAISER. This clamp LOWERS. Routing the lowest marker through `cap_floor`
477    // yields `max(base, marker)`, which still sits above the marker in exactly
478    // the poisoning case and would raise floors in cases that work today. The
479    // clamp therefore has its own named primitive, `marker_clamped_floor`, and
480    // never travels through `cap_floor`.
481    //
482    // The `Precedence` refusal STAYS as a backstop invariant. After this clamp
483    // it should be unreachable from this path, and a reachable backstop firing
484    // is a bug report rather than control flow.
485    //
486    // WHY A CLAMP BESIDE `search_capacity_floor` AND NOT A REUSE OF IT
487    // (PRECEDENCE-CLAMP M3, decided rather than guessed).
488    // `ordinary_record_projection.rs`'s `search_capacity_floor` does the
489    // structurally similar thing for its own invariant, and it is the wrong
490    // tool here for three independent reasons, any one of which is fatal:
491    //   1. DIRECTION. It SEARCHES UPWARD, walking the floor to successively
492    //      higher retained sequences until a capacity/credit baseline admits,
493    //      and it feeds its answer back through `floor_transition`'s `cap_floor`
494    //      — a raiser. Precedence needs the floor LOWERED.
495    //   2. INVARIANT. Its subject is capacity: whether the resulting retained
496    //      charge fits the configured cap. Precedence's subject is replayability:
497    //      an unacked marker must stay retained. It treats a marker anchor below
498    //      its chosen floor as an ERROR (`MarkerAnchorCapacity`) rather than as a
499    //      bound, which is the opposite of pinning.
500    //   3. INPUTS. It is a function of `OrdinaryProjectionFacts` — an order
501    //      ledger, a sequence ledger, an admission request, encoded charges —
502    //      none of which exist at a binding-fate measurement.
503    // The two are NOT interchangeable, so the marker rule gets its own named
504    // primitive, `marker_clamped_floor`, and the two live side by side.
505    let lowest_retained_marker_seq = owner
506        .frontiers()
507        .retained_marker_records()
508        .iter()
509        .map(|record| record.delivery_seq)
510        .min();
511    let clamped_floor = marker_clamped_floor(measured.resulting_floor, lowest_retained_marker_seq);
512    let Ok(resulting_floor) = DeliverySeq::try_from(clamped_floor) else {
513        return Err(BindingFateMeasurementError::ResultingFloor);
514    };
515    Ok(ValidatedBindingFateFloor {
516        participant_id: context.participant_id,
517        binding_epoch: context.binding_epoch,
518        resulting_floor,
519    })
520}
521
522fn refusal(
523    owner: LiveFrontierOwner,
524    token: SealedBindingFateToken,
525    terminal: BindingFateTerminal,
526    error: BindingFateMeasurementError,
527) -> Result<PreparedBindingFate, Box<BindingFateMeasurementRefused>> {
528    Err(Box::new(BindingFateMeasurementRefused {
529        owner,
530        token,
531        terminal,
532        error,
533    }))
534}