Skip to main content

liminal_protocol/lifecycle/
aggregate_commit.rs

1//! Total aggregate-level commits for the six lifecycle operations.
2//!
3//! `docs/design/LP-GAP-CLOSURE-GOAL.md` item A3: every producer here consumes
4//! one of the crate's existing public typed commit results — no operation
5//! logic is re-derived — mints the exact A1 event body from that consumed
6//! value, and selects the shell event under the [`ConversationCommit`]
7//! durability barrier. The consumed operation value travels inside the
8//! barrier: it becomes reachable again only together with the advanced shell
9//! (after a confirmed durable append) or together with the byte-for-byte
10//! unchanged shell (after a failed append), so a crash between event
11//! selection and durable append leaves the shell unadvanced and loses no
12//! operation authority.
13//!
14//! Atomicity laws recorded by these commits:
15//!
16//! * **Detach** is ONE event: the consumed [`CommittedDetachTransition`]
17//!   carries the terminal append, the floor transition, the replay-cell
18//!   replacement, and the binding release as one non-decomposable value, and
19//!   the single `Detached` event summarizes exactly that transaction.
20//! * **Attach** records Fix 1: the consumed [`AttachCommit`] already
21//!   terminalized its `Committed` detach cell atomically with the credential
22//!   rotation, so the one `Attached` event covers the
23//!   `Committed → Terminalized` cell transition.
24//! * **Leave** consumes the whole [`LeaveCommit`] WITH its claim frontiers;
25//!   the tombstone is never split from the frontier authority committed in
26//!   the same Leave transaction.
27//! * **The nonzero-debt ack** records Fix 2's per-participant cursor-fact
28//!   accounting: the event body's `(participant, through_seq)` pair is the
29//!   exact `(participant_index, boundary)` cursor-fact key committed into the
30//!   consumed commit's resulting episode.
31//!
32//! Record admission is deliberately absent from this surface: it is the one
33//! conversation mutation that mints no shell event. Its aggregate feed is the
34//! moved [`RecordAdmissionPersistenceParts`](super::RecordAdmissionPersistenceParts)
35//! payload (item A2), persisted by the durable writer in one atomic
36//! transaction without advancing the shell's event log.
37
38use super::operation_event::{
39    AttachedOperation, BindingFateOperation, ConversationOperation, DetachedOperation,
40    EnrolledOperation, LeftOperation, NonzeroDebtAckOperation,
41};
42use super::{
43    AttachCommit, CommittedDetachTransition, ConversationCommit, ConversationDecision,
44    ConversationEvent, ConversationRefusal, ConversationRefusalReason, EnrollmentCommit,
45    IdentityState, LeaveCommit, NonzeroParticipantAckCommit, OrdinaryBindingFate,
46    ParticipantConversation, RecoveredBindingFate,
47};
48
49use alloc::boxed::Box;
50
51/// Aggregate durability barrier pairing one selected shell event with the
52/// consumed typed operation commit.
53///
54/// Both fields are private: while the event is speculative, neither the shell
55/// nor the consumed operation authority is reachable, so nothing can be
56/// persisted or executed from a not-yet-durable decision.
57///
58/// ```compile_fail
59/// use liminal_protocol::lifecycle::AggregateOperationCommit;
60///
61/// fn leak<T>(commit: AggregateOperationCommit<T>) {
62///     let _ = commit.operation;
63/// }
64/// ```
65#[derive(Debug, PartialEq, Eq)]
66pub struct AggregateOperationCommit<T> {
67    shell: ConversationCommit,
68    operation: T,
69}
70
71impl<T> AggregateOperationCommit<T> {
72    /// Borrows the exact event that must be durably appended before commit.
73    #[must_use]
74    pub const fn event(&self) -> &ConversationEvent {
75        self.shell.event()
76    }
77
78    /// Consumes the barrier after a confirmed durable append.
79    ///
80    /// Returns the advanced shell together with the intact typed operation
81    /// commit, so the caller persists the operation's own resulting state in
82    /// the same durable transaction as the appended event.
83    #[must_use]
84    pub fn commit(self) -> (ParticipantConversation, T) {
85        (self.shell.commit(), self.operation)
86    }
87
88    /// Cancels a failed durable append.
89    ///
90    /// Returns the byte-for-byte unchanged shell pre-state together with the
91    /// intact typed operation commit; nothing advanced and no authority was
92    /// dropped.
93    #[must_use]
94    pub fn abort(self) -> (ParticipantConversation, T) {
95        (self.shell.abort(), self.operation)
96    }
97}
98
99/// Refused aggregate decision retaining the unchanged shell and the intact
100/// consumed operation commit.
101#[derive(Debug, PartialEq, Eq)]
102pub struct AggregateOperationRefusal<T> {
103    refusal: ConversationRefusal,
104    operation: T,
105}
106
107impl<T> AggregateOperationRefusal<T> {
108    /// Returns the shell's stable refusal reason.
109    #[must_use]
110    pub const fn reason(&self) -> ConversationRefusalReason {
111        self.refusal.reason()
112    }
113
114    /// Recovers the unchanged shell and the intact operation commit.
115    #[must_use]
116    pub fn into_parts(self) -> (ParticipantConversation, T) {
117        (self.refusal.into_conversation(), self.operation)
118    }
119}
120
121/// Total aggregate decision for one lifecycle operation.
122#[derive(Debug, PartialEq, Eq)]
123pub enum AggregateOperationDecision<T> {
124    /// Append the selected event, then consume the barrier into usable state.
125    Commit(AggregateOperationCommit<T>),
126    /// The shell refused the event; shell and operation are both recoverable.
127    Refused(AggregateOperationRefusal<T>),
128}
129
130/// Reason a consumed typed commit could not repeat itself as an event body.
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum AggregateOperationFaultReason {
133    /// The detach transition's committed terminal and replay cell did not
134    /// name one detach commit.
135    DetachTerminalCellMismatch,
136    /// The leave commit's identity result was not a permanent tombstone.
137    LeaveIdentityNotRetired,
138}
139
140/// Fail-loud invariant report: a consumed typed commit disagreed with itself.
141///
142/// The crate's own commit constructors make both reasons unreachable; this
143/// arm exists so a violated internal pairing invariant surfaces as a typed
144/// error carrying the unchanged shell and the intact operation value, never
145/// as a silently recorded lie and never as a panic.
146#[derive(Debug, PartialEq, Eq)]
147pub struct AggregateOperationFault<T> {
148    shell: ParticipantConversation,
149    operation: T,
150    reason: AggregateOperationFaultReason,
151}
152
153impl<T> AggregateOperationFault<T> {
154    /// Returns the exact violated pairing invariant.
155    #[must_use]
156    pub const fn reason(&self) -> AggregateOperationFaultReason {
157        self.reason
158    }
159
160    /// Recovers the unchanged shell and the intact operation commit.
161    #[must_use]
162    pub fn into_parts(self) -> (ParticipantConversation, T) {
163        (self.shell, self.operation)
164    }
165}
166
167/// Total aggregate decision, or the fail-loud pairing fault for the two
168/// producers whose event bodies revalidate their consumed commit.
169pub type AggregateOperationResult<T> =
170    Result<AggregateOperationDecision<T>, Box<AggregateOperationFault<T>>>;
171
172const fn decide<T>(
173    conversation: ParticipantConversation,
174    operation: ConversationOperation,
175    payload: T,
176) -> AggregateOperationDecision<T> {
177    match conversation.decide_operation(operation) {
178        ConversationDecision::Commit(shell) => {
179            AggregateOperationDecision::Commit(AggregateOperationCommit {
180                shell,
181                operation: payload,
182            })
183        }
184        ConversationDecision::Refused(refusal) => {
185            AggregateOperationDecision::Refused(AggregateOperationRefusal {
186                refusal,
187                operation: payload,
188            })
189        }
190    }
191}
192
193/// Selects the aggregate `Enrolled` event for one consumed enrollment commit.
194///
195/// The event body repeats the exact committed `Attached` record carried by
196/// [`super::commit_enrollment`]'s result; the commit itself returns intact
197/// through the durability barrier.
198#[must_use]
199pub const fn decide_enrolled_operation<F>(
200    conversation: ParticipantConversation,
201    commit: EnrollmentCommit<F>,
202) -> AggregateOperationDecision<EnrollmentCommit<F>> {
203    let body = EnrolledOperation::new(&commit);
204    decide(conversation, ConversationOperation::Enrolled(body), commit)
205}
206
207/// Selects the aggregate `Attached` event for one consumed attach commit.
208///
209/// Fix 1 rides inside the consumed [`AttachCommit`]: its detach cell was
210/// terminalized `Committed → Terminalized` atomically with the rotation, so
211/// this single event records that whole transaction.
212#[must_use]
213pub const fn decide_attached_operation<F, V>(
214    conversation: ParticipantConversation,
215    commit: AttachCommit<F, V>,
216) -> AggregateOperationDecision<AttachCommit<F, V>> {
217    let body = AttachedOperation::new(&commit);
218    decide(conversation, ConversationOperation::Attached(body), commit)
219}
220
221/// Selects the aggregate `Detached` event for one consumed detach transition.
222///
223/// Detach is ONE event: the consumed [`CommittedDetachTransition`] carries
224/// the terminal append, floor transition, cell replacement, and binding
225/// release together, and the event summarizes exactly that committed
226/// transaction, recording the replay cell's own attempt token.
227///
228/// # Errors
229///
230/// Returns [`AggregateOperationFault`] with
231/// [`AggregateOperationFaultReason::DetachTerminalCellMismatch`] if the
232/// transition's terminal and cell fail their pairing revalidation — an
233/// internal invariant [`super::commit_detach`] makes unreachable. The shell
234/// and transition return unchanged.
235pub fn decide_detached_operation<EF, V>(
236    conversation: ParticipantConversation,
237    transition: CommittedDetachTransition<EF, V>,
238) -> AggregateOperationResult<CommittedDetachTransition<EF, V>> {
239    let Some(body) = DetachedOperation::new(&transition) else {
240        return Err(Box::new(AggregateOperationFault {
241            shell: conversation,
242            operation: transition,
243            reason: AggregateOperationFaultReason::DetachTerminalCellMismatch,
244        }));
245    };
246    Ok(decide(
247        conversation,
248        ConversationOperation::Detached(body),
249        transition,
250    ))
251}
252
253/// Selects the aggregate `Left` event for one consumed leave commit.
254///
255/// The whole [`LeaveCommit`] — tombstone AND the claim frontiers committed in
256/// the same Leave transaction — is consumed and returned intact through the
257/// barrier, so the frontier authority can never be split from the event that
258/// records its Leave.
259///
260/// # Errors
261///
262/// Returns [`AggregateOperationFault`] with
263/// [`AggregateOperationFaultReason::LeaveIdentityNotRetired`] if the commit's
264/// identity result is not a permanent tombstone — an internal invariant
265/// [`super::commit_leave`] and [`super::commit_pending_leave`] make
266/// unreachable. The shell and commit return unchanged.
267pub fn decide_left_operation<EF, V, LF>(
268    conversation: ParticipantConversation,
269    commit: LeaveCommit<EF, V, LF>,
270) -> AggregateOperationResult<LeaveCommit<EF, V, LF>> {
271    let body = match commit.identity() {
272        IdentityState::Retired(retired) => LeftOperation::new(retired),
273        IdentityState::Live(_) => {
274            return Err(Box::new(AggregateOperationFault {
275                shell: conversation,
276                operation: commit,
277                reason: AggregateOperationFaultReason::LeaveIdentityNotRetired,
278            }));
279        }
280    };
281    Ok(decide(
282        conversation,
283        ConversationOperation::Left(body),
284        commit,
285    ))
286}
287
288/// Selects the aggregate `BindingFate` event for one consumed ordinary fate.
289///
290/// The fate authority returns intact through the barrier so the caller can
291/// apply it to the stored closure edge in the same durable transaction.
292#[must_use]
293pub const fn decide_ordinary_binding_fate_operation(
294    conversation: ParticipantConversation,
295    fate: OrdinaryBindingFate,
296) -> AggregateOperationDecision<OrdinaryBindingFate> {
297    let body = BindingFateOperation::from_ordinary(&fate);
298    decide(conversation, ConversationOperation::BindingFate(body), fate)
299}
300
301/// Selects the aggregate `BindingFate` event for one consumed recovered fate.
302///
303/// The non-cloneable fate authority returns intact through the barrier so
304/// the caller can consume it through the exact stored edge's
305/// `apply_recovered_binding_fate` transition in the same durable transaction.
306#[must_use]
307pub const fn decide_recovered_binding_fate_operation(
308    conversation: ParticipantConversation,
309    fate: RecoveredBindingFate,
310) -> AggregateOperationDecision<RecoveredBindingFate> {
311    let body = BindingFateOperation::from_recovered(&fate);
312    decide(conversation, ConversationOperation::BindingFate(body), fate)
313}
314
315/// Selects the aggregate `NonzeroDebtAck` event for one consumed ack commit.
316///
317/// Fix 2's per-participant cursor-fact accounting rides with the consumed
318/// commit: the event body's `(participant, through_seq)` pair is the exact
319/// `(participant_index, boundary)` cursor-fact key recorded in the commit's
320/// resulting episode, which returns intact through the barrier for the same
321/// durable transaction as the event append.
322#[must_use]
323pub fn decide_nonzero_debt_ack_operation(
324    conversation: ParticipantConversation,
325    commit: Box<NonzeroParticipantAckCommit>,
326) -> AggregateOperationDecision<Box<NonzeroParticipantAckCommit>> {
327    let body = NonzeroDebtAckOperation::new(&commit);
328    decide(
329        conversation,
330        ConversationOperation::NonzeroDebtAck(body),
331        commit,
332    )
333}