liminal-protocol 0.14.1

Shared participant-lifecycle protocol types for liminal
Documentation
//! What a candidate-lane terminal drain does to the owning identity.
//!
//! Decision of 11 October 2026: a drained `Died` releases its identity from
//! the frontier, as a `Left` would, consistent with the 2026-07-23 flavor
//! ruling under which the server erases a drained `Died` slot and enrollment
//! token. A drained `Detached` keeps its identity, detached at its dead epoch
//! (S-16: slot and token preserved, exact-secret resumable).

use alloc::{
    format,
    string::{String, ToString},
};

use crate::{
    algebra::ResourceVector,
    wire::{BindingEpoch, ConnectionIncarnation, Generation},
};

use super::{BindingTerminalAdmission, BindingTerminalCauseClass, LiveFrontierOwner};
use crate::lifecycle::{
    ActiveBinding, BindingTerminalDisposition, DetachedBindingTransition, DiedBindingTransition,
    FrontierBinding, PendingFinalization, RetainedRecordCharge,
};

const CONVERSATION: u64 = 1;
const PARTICIPANT: u64 = 0;
const HIGH_WATERMARK: u64 = 3;

/// One participant whose terminal pended at a zero retained-record cap, and
/// the pending finalization of the requested flavor.
fn pending_terminal_owner(
    class: BindingTerminalCauseClass,
) -> Result<(LiveFrontierOwner, PendingFinalization, BindingEpoch), String> {
    let binding_epoch = BindingEpoch::new(
        ConnectionIncarnation::new(1, 2),
        Generation::new(1).ok_or_else(|| "test generation must be nonzero".to_string())?,
    );
    let active = ActiveBinding {
        participant_id: PARTICIPANT,
        conversation_id: CONVERSATION,
        binding_epoch,
    };
    let owner = super::binding_fate_tests::frontier_owner_with_limit(
        CONVERSATION,
        PARTICIPANT,
        binding_epoch,
        0,
        HIGH_WATERMARK,
        0,
    )?;
    let prepared = owner
        .prepare_binding_terminal(active, class, 0, HIGH_WATERMARK + 1, HIGH_WATERMARK)
        .map_err(|refused| format!("terminal prepare refused: {:?}", refused.error()))?;
    let key = prepared.candidate_key();
    let BindingTerminalAdmission::Pending(pending) =
        prepared.admit(key.bind_v3_charge(ResourceVector::new(1, 73)))
    else {
        return Err("the zero-cap terminal did not pend".to_string());
    };
    let (owner, position) = pending.into_parts();
    let disposition = BindingTerminalDisposition::Pending(position);
    let finalization = match class {
        BindingTerminalCauseClass::Died => match active.connection_lost(disposition) {
            DiedBindingTransition::Pending(died) => PendingFinalization::Died(died),
            DiedBindingTransition::Committed(_) => {
                return Err("a pending position committed the Died terminal".to_string());
            }
        },
        BindingTerminalCauseClass::Detached => match active.clean_deregister(disposition) {
            DetachedBindingTransition::Pending(detached) => PendingFinalization::Detached(detached),
            DetachedBindingTransition::Committed(_) => {
                return Err("a pending position committed the Detached terminal".to_string());
            }
        },
    };
    Ok((owner, finalization, binding_epoch))
}

fn drain(
    owner: LiveFrontierOwner,
    pending: PendingFinalization,
) -> Result<LiveFrontierOwner, String> {
    let charge = RetainedRecordCharge::new(
        HIGH_WATERMARK + 1,
        pending.admission_order(),
        ResourceVector::new(1, 73),
    );
    let drained = owner
        .drain_pending_terminal(pending, charge)
        .map_err(|refused| format!("terminal drain refused: {:?}", refused.error()))?;
    Ok(drained.into_parts().0)
}

#[test]
fn a_drained_died_leaves_the_frontier_and_frees_its_identity_slot() -> Result<(), String> {
    let (owner, pending, _) = pending_terminal_owner(BindingTerminalCauseClass::Died)?;
    let before = owner.frontiers();
    assert_eq!(before.active_identities().len(), 1);
    assert_eq!(before.identity_slot_limit(), 1, "the fixture has one slot");
    assert_eq!(before.sequence().ledger().claims().live_members(), 1);
    assert_eq!(before.order().ledger().claims().membership_exits(), 1);

    let drained = drain(owner, pending)?;
    let frontiers = drained.frontiers();
    assert!(
        frontiers
            .active_identities()
            .participants()
            .iter()
            .all(|participant| participant.participant_index() != PARTICIPANT),
        "the drained Died identity stayed in the frontier's active identities"
    );
    assert_eq!(
        frontiers.active_identities().len(),
        0,
        "the drained Died identity still holds its identity slot"
    );
    assert!(
        frontiers.active_identities().len() < frontiers.identity_slot_limit(),
        "the identity slot the drained Died held is not free"
    );
    assert_eq!(
        frontiers.sequence().ledger().claims().live_members(),
        0,
        "the drained Died identity kept its membership-exit sequence claim"
    );
    assert_eq!(
        frontiers.order().ledger().claims().membership_exits(),
        0,
        "the drained Died identity kept its membership-exit order claim"
    );
    assert_eq!(
        frontiers.sequence().ledger().high_watermark(),
        HIGH_WATERMARK + 1,
        "the drain did not sequence the terminal row"
    );
    assert!(
        frontiers.sequence().immutable_candidates().is_empty(),
        "the drain left its candidate in the lane"
    );
    Ok(())
}

#[test]
fn a_drained_detached_keeps_its_identity_at_its_dead_epoch() -> Result<(), String> {
    let (owner, pending, binding_epoch) =
        pending_terminal_owner(BindingTerminalCauseClass::Detached)?;
    let drained = drain(owner, pending)?;
    let frontiers = drained.frontiers();
    let [participant] = frontiers.active_identities().participants() else {
        return Err("the drained Detached identity left the frontier".to_string());
    };
    assert_eq!(participant.participant_index(), PARTICIPANT);
    assert_eq!(
        participant.binding(),
        FrontierBinding::Detached(binding_epoch),
        "the drained Detached identity is not detached at its dead epoch"
    );
    assert_eq!(frontiers.sequence().ledger().claims().live_members(), 1);
    assert_eq!(frontiers.order().ledger().claims().membership_exits(), 1);
    Ok(())
}