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}