Skip to main content

liminal_server/server/participant/
dispatch.rs

1//! Participant transport-to-semantics dispatch boundary.
2//!
3//! This module contains no lifecycle rules. The shared protocol crate gates and
4//! decodes inbound frames, while an injected semantic handler returns one typed
5//! protocol value. The server then performs only generic-frame encoding.
6
7use std::collections::BTreeSet;
8use std::sync::{Arc, Weak};
9
10use liminal::durability::DurableStore;
11use liminal::protocol::Frame;
12use liminal_protocol::lifecycle::{BindingTerminalAdmitError, ConnectionConversationTracking};
13use liminal_protocol::wire::{
14    BindingEpoch, ClientRequest, CodecError, ConnectionIncarnation, ConversationId,
15    ObserverRecoveryHandshake, ParticipantId, ServerValue, ValidatedFrameLimit,
16};
17
18use crate::auth_pass::PassPrincipal;
19use crate::server::mount::MountKind;
20
21use super::dispatch_impact::DispatchImpact;
22use super::transport::{
23    ParticipantIngress, ParticipantSession, encode_server_value, gate_generic_frame,
24    normalize_configured_frame_limit,
25};
26use super::{
27    MarkerSettledPublication, ObserverPublicationTarget, ParticipantOfferedProgress,
28    ParticipantPublication, ParticipantPublicationInbox, ParticipantPublicationRegistry,
29};
30
31mod settle;
32pub use settle::MARKER_SETTLED_FOLLOWS_EVERY_SETTLEMENT_REFUSAL;
33
34/// One connection waiting on one settlement epoch, installed by its OWN refusal.
35///
36/// ⛔ This registry is the whole of §0.16 build obligation 3. The wake is
37/// delivered "exactly to connections that received the refusal in this process
38/// lifetime": a waiter can only be created from a
39/// `ServerValue::MarkerSettlementBackpressure` leaving THIS connection's own
40/// request, and the fire path walks waiters — never conversation membership,
41/// never the publication registry's incarnation map. There is deliberately no
42/// code path from a conversation id to a set of connections here, because that
43/// is exactly the settlement-timing side channel the amendment outlaws.
44///
45/// A connection refused at the ENROLLMENT wrapper cannot appear: its refusal is
46/// `EnrollmentSettlementBackpressure`, a different `ServerValue` variant with no
47/// epoch, and the registration match does not admit it.
48#[derive(Debug)]
49struct MarkerSettlementWaiter {
50    connection_incarnation: ConnectionIncarnation,
51    refused_epoch: u64,
52    target: ObserverPublicationTarget,
53}
54
55/// Told when a committed drain removes a head of one conversation's
56/// immutable-candidate lane.
57///
58/// The connection runtime installs itself here so a connection fate parked on
59/// candidate-lane `Precedence` is retried by the drain that removed the
60/// refusal, on the thread that committed it, and never by a timer or by
61/// whichever unrelated reply happens to come next. A lane drain is the only
62/// event that can turn that refusal into an admission, so it is the only
63/// event that retries.
64pub trait CandidateLaneListener: Send + Sync + std::fmt::Debug {
65    /// A drain of `conversation_id`'s candidate lane is durable and its
66    /// resulting owner installed. Runs after the conversation lock is released.
67    fn candidate_lane_drained(&self, conversation_id: ConversationId);
68}
69
70/// Connection-local semantic-conversation dispatch map (contract R-D1: the
71/// connection's binding/interest/dispatch maps are bounded by the signed
72/// `max_semantic_conversations_per_connection`).
73///
74/// One value lives in each connection process's state for the connection's
75/// lifetime and is dropped with it. A conversation enters the map exactly
76/// when a semantic operation for it COMMITS on this connection (the crate's
77/// `ConnectionConversationCapacityCommit::newly_tracked` verdict) or when an
78/// observer-recovery batch arms its refusal-only recipient; refusals and
79/// replays leave the map untouched, exactly as the crate's stage-6 selector
80/// leaves its counter unchanged. Growth is therefore bounded by the signed
81/// limit the stage-6 selector enforces.
82#[derive(Debug, Default)]
83pub struct ParticipantConnectionConversations {
84    tracked: BTreeSet<ConversationId>,
85}
86
87impl ParticipantConnectionConversations {
88    /// Stage-6 tracking fact for one conversation on this connection.
89    #[must_use]
90    pub fn tracking(&self, conversation_id: ConversationId) -> ConnectionConversationTracking {
91        if self.tracked.contains(&conversation_id) {
92            ConnectionConversationTracking::AlreadyTracked
93        } else {
94            ConnectionConversationTracking::Untracked
95        }
96    }
97
98    /// Current connection-conversation occupancy.
99    #[must_use]
100    pub fn occupied(&self) -> u64 {
101        // `usize` fits `u64` on every supported target; if that ever stopped
102        // holding, saturating at MAX fails CLOSED (capacity reads as full)
103        // rather than silently under-counting occupancy.
104        u64::try_from(self.tracked.len()).unwrap_or(u64::MAX)
105    }
106
107    /// Installs one conversation slot after a capacity-committing operation.
108    pub fn track(&mut self, conversation_id: ConversationId) {
109        self.tracked.insert(conversation_id);
110    }
111
112    /// Sorted tracked conversations (the observer-recovery preflight's
113    /// current-occupancy input).
114    #[must_use]
115    pub fn tracked_conversations(&self) -> Vec<ConversationId> {
116        self.tracked.iter().copied().collect()
117    }
118}
119
120/// Connection-scoped authority facts supplied to participant semantics.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct ParticipantConnectionContext {
123    connection_incarnation: ConnectionIncarnation,
124    mount: MountKind,
125}
126
127impl ParticipantConnectionContext {
128    /// Captures the durably allocated incarnation of the receiving connection
129    /// and the mount its admitting door stamped.
130    ///
131    /// Both arguments are server facts. `mount` in particular is supplied by
132    /// the spawn path from its own knowledge of which door it is (design §10);
133    /// it is a required argument rather than a defaulted field precisely so a
134    /// new transport cannot acquire a mount attestation by forgetting to say
135    /// which one it is.
136    #[must_use]
137    pub const fn new(connection_incarnation: ConnectionIncarnation, mount: MountKind) -> Self {
138        Self {
139            connection_incarnation,
140            mount,
141        }
142    }
143
144    /// Returns the durably allocated receiving-connection incarnation.
145    #[must_use]
146    pub const fn connection_incarnation(self) -> ConnectionIncarnation {
147        self.connection_incarnation
148    }
149
150    /// Returns the mount the admitting door stamped on this connection.
151    ///
152    /// This is the mount attestation the consumer's door reads before stamping
153    /// its own append. Nothing a client sends can move it: the value was fixed
154    /// by the spawn path before the connection's first inbound byte was read.
155    #[must_use]
156    pub const fn mount(self) -> MountKind {
157        self.mount
158    }
159}
160
161/// Exact terminal classification preserved from a connection's close trigger.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum ConnectionFateClass {
164    /// A protocol-level clean Disconnect.
165    CleanDisconnect,
166    /// An orderly server `ForceClose`.
167    ServerShutdown,
168    /// EOF or transport loss without clean protocol evidence.
169    ConnectionLost,
170    /// A terminal protocol/decode refusal after participant binding.
171    ProtocolError,
172}
173
174/// One durable bounded connection-fate intent delivered to participant semantics.
175#[derive(Clone, Debug, PartialEq, Eq)]
176pub struct ConnectionFateWorkItem {
177    /// Durable incarnation-stream Open sequence used by participant source rows.
178    pub open_sequence: u64,
179    /// Exact connection whose current Bound slots are eligible.
180    pub connection_incarnation: ConnectionIncarnation,
181    /// Preserved close classification.
182    pub class: ConnectionFateClass,
183    /// Canonical sorted tracked-conversation snapshot owned by the Open.
184    pub tracked_conversations: Vec<ConversationId>,
185}
186
187/// Process-wide terminal participant-service latch.
188#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
189pub enum ParticipantServiceFatal {
190    /// A durable Open landed but one listed conversation could not durably finish its fate.
191    #[error(
192        "connection-fate intent {open_sequence} is incomplete at conversation {conversation_id}"
193    )]
194    ConnectionFateIntentIncomplete {
195        /// Durable incarnation-stream Open sequence.
196        open_sequence: u64,
197        /// Exact conversation whose non-idempotent completion failed.
198        conversation_id: ConversationId,
199    },
200}
201
202/// Why F8B boot recovery could not empty a restored conversation's
203/// immutable-candidate lane (`docs/design/F8B-INTENT-DEADLOCK.md` §6.2
204/// R-BOOT-VERDICT).
205///
206/// The discrimination is BY TYPE, for the same reason
207/// [`ParticipantSemanticError::BindingTerminalAdmissionRefused`] carries
208/// [`BindingTerminalAdmitError`]: a consumer deciding what a refused boot
209/// means must not read it out of a formatted message.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum BootDrainRefusal {
212    /// The lane head is a pending binding terminal under an armed
213    /// fenced-attach recovery block. The terminal drain refuses outright while
214    /// a recovery block is armed, and the only consumer of a recovery block is
215    /// a live fenced attach — which boot cannot perform. Such a store is not
216    /// repairable by the boot drain, and this verdict is the honest answer
217    /// rather than a repair.
218    RecoveryArmed,
219    /// Any other drain refusal: the head was reachable, the drain was
220    /// attempted, and the protocol refused the transition.
221    Shape,
222}
223
224/// Non-wire semantic service failure.
225///
226/// A failure is terminal to the connection attempt. It is deliberately not
227/// convertible to [`ServerValue`], preventing the server from inventing a
228/// lifecycle response when the protocol-owned transition did not produce one.
229#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
230pub enum ParticipantSemanticError {
231    /// The complete semantic service is not installed.
232    #[error("participant semantic service is unavailable")]
233    Unavailable,
234    /// Durable state or a protocol invariant prevented semantic completion.
235    #[error("participant semantic service failed: {message}")]
236    Internal {
237        /// Diagnostic text for server logs; never placed on the participant wire.
238        message: String,
239    },
240    /// The request's durable write ended with an outcome the store could not
241    /// tell and the server could not settle by reading it back: it may have
242    /// been applied. The dispatch answers a token-carrying request with its
243    /// write-outcome-unknown value (`0x0128`) on an open connection.
244    #[error("participant durable write outcome unknown: {message}")]
245    WriteOutcomeUnknown {
246        /// Diagnostic text for server logs; never placed on the participant wire.
247        message: String,
248    },
249    /// A process-wide participant fatal has already latched.
250    #[error(transparent)]
251    ServiceFatal(ParticipantServiceFatal),
252    /// A keyed binding-terminal candidate was refused, carrying the protocol's
253    /// own reason rather than a formatted description of it.
254    ///
255    /// [`BindingTerminalAdmitError::Precedence`] is lane occupancy: the
256    /// conversation's immutable-candidate lane already holds a terminal
257    /// awaiting its drain. That is a designed structural boundary, and a
258    /// caller deciding whether to park or to treat the refusal as corruption
259    /// must be able to tell it from the five genuine authority defects by
260    /// type.
261    #[error("participant binding-terminal admission refused: {error:?}")]
262    BindingTerminalAdmissionRefused {
263        /// Exact protocol refusal reason.
264        error: BindingTerminalAdmitError,
265    },
266    /// F8B R-BOOT-VERDICT: boot recovery could not empty a restored
267    /// conversation's immutable-candidate lane, so the boot refuses HERE,
268    /// naming the conversation and the shape, instead of starting and dying
269    /// several collapses downstream on a retained `Open` it can never replay.
270    #[error(
271        "participant boot drain refused conversation {conversation_id} on lane head {candidate} \
272         ({refusal:?}): {reason} — docs/design/F8B-INTENT-DEADLOCK.md §6.2 R-BOOT-VERDICT"
273    )]
274    BootDrainRefused {
275        /// Conversation whose restored lane refused its drain.
276        conversation_id: ConversationId,
277        /// Typed reason, so a consumer never discriminates on a substring.
278        refusal: BootDrainRefusal,
279        /// Exact lane head that refused, rendered for the operator.
280        candidate: String,
281        /// The drain's own refusal text.
282        reason: String,
283    },
284    /// F8B R-SEAL: the conversation is Closed — a Died-flavor drain erased its
285    /// final enrollment token, so its log holds records, terminals and drain
286    /// rows but no live identity can ever be reached through it again.
287    ///
288    /// Enrollment answers with this NAMED refusal rather than falling through
289    /// to a fresh identity, which would silently re-open a conversation whose
290    /// history has already ended. On the wire it rides the existing
291    /// semantic-error framing; a protocol-native response value with its own
292    /// discriminant is deferred to a protocol-version leg (§9.8).
293    #[error(
294        "participant enrollment refused: conversation {conversation_id} is sealed — its final \
295         enrollment token was erased by a binding-terminal drain — \
296         docs/design/F8B-INTENT-DEADLOCK.md §6.6 R-SEAL"
297    )]
298    ConversationSealed {
299        /// Conversation whose closure refused the request.
300        conversation_id: ConversationId,
301    },
302    /// CONTAINMENT: this one conversation's durable state cannot be loaded, so
303    /// this one conversation is refused. The node starts, every other
304    /// conversation is served, and the refusal NAMES its subject.
305    ///
306    /// Attribution is not decoration here, it is half the property. A node
307    /// that contains an unloadable conversation without naming it boots clean
308    /// and silently serves nothing on that conversation forever, which is
309    /// worse than the crash it replaced, because the crash was the only thing
310    /// telling anyone. The underlying failure travels as `reason` rather than
311    /// as a wrapped error because it is already a rendered diagnostic by the
312    /// time replay refuses it.
313    #[error(
314        "participant conversation {conversation_id} is unloadable and is refused on its own: \
315         {reason}"
316    )]
317    ConversationUnloadable {
318        /// Conversation whose durable state could not be loaded.
319        conversation_id: ConversationId,
320        /// The load failure's own text, as the operator needs to see it.
321        reason: String,
322    },
323}
324
325impl ParticipantSemanticError {
326    /// Stable operator-facing class for this refusal.
327    ///
328    /// The rendered message is a diagnostic: it carries the subject and the
329    /// detail, and it is allowed to move. This is the discriminant an operator
330    /// surface and a log field can be read against without matching on a
331    /// substring — which is what the containment record's consumers need, since
332    /// the failure text a refused load carries ("expected value at line 1
333    /// column 1") names no class at all on its own.
334    #[must_use]
335    pub const fn class(&self) -> &'static str {
336        match self {
337            Self::Unavailable => "unavailable",
338            Self::Internal { .. } => "internal",
339            Self::WriteOutcomeUnknown { .. } => "write_outcome_unknown",
340            Self::ServiceFatal(_) => "service_fatal",
341            Self::BindingTerminalAdmissionRefused { .. } => "binding_terminal_admission_refused",
342            Self::BootDrainRefused { .. } => "boot_drain_refused",
343            Self::ConversationSealed { .. } => "conversation_sealed",
344            Self::ConversationUnloadable { .. } => "conversation_unloadable",
345        }
346    }
347}
348
349/// One semantic result paired with every dispatch effect durably installed by
350/// the request before it returned.
351///
352/// The envelope deliberately owns the `Result`: a marker-drain prefix can
353/// commit before a later retry fails, and that failure must not erase the
354/// prefix's post-commit tell.
355#[derive(Debug)]
356pub struct ParticipantSemanticOutcome<T> {
357    result: Result<T, ParticipantSemanticError>,
358    impact: DispatchImpact,
359}
360
361impl<T> ParticipantSemanticOutcome<T> {
362    /// Wraps a fixture or operation which installed no dispatch effect.
363    #[must_use]
364    pub const fn unchanged(result: Result<T, ParticipantSemanticError>) -> Self {
365        Self {
366            result,
367            impact: DispatchImpact::Unchanged,
368        }
369    }
370
371    /// Carries an operation result and its complete request accumulator.
372    #[must_use]
373    pub const fn new(result: Result<T, ParticipantSemanticError>, impact: DispatchImpact) -> Self {
374        Self { result, impact }
375    }
376
377    pub(crate) fn into_parts(self) -> (Result<T, ParticipantSemanticError>, DispatchImpact) {
378        (self.result, self.impact)
379    }
380
381    /// Returns the semantic result when an internal caller has no notification
382    /// boundary. Production request dispatch uses the complete envelope; this
383    /// projection exists for the trait's legacy direct-call entry point.
384    pub(crate) fn into_result(self) -> Result<T, ParticipantSemanticError> {
385        self.result
386    }
387}
388
389/// One connection-fate result paired with every conversation impact committed
390/// before the fate operation returned.
391#[derive(Debug)]
392pub struct ParticipantConnectionFateOutcome {
393    result: Result<(), ParticipantSemanticError>,
394    impacts: Vec<DispatchImpact>,
395}
396
397impl ParticipantConnectionFateOutcome {
398    /// Wraps a fixture fate handler which committed no dispatch impact.
399    #[must_use]
400    pub const fn unchanged(result: Result<(), ParticipantSemanticError>) -> Self {
401        Self {
402            result,
403            impacts: Vec::new(),
404        }
405    }
406
407    /// Carries a fate result and every committed per-conversation impact.
408    #[must_use]
409    pub const fn new(
410        result: Result<(), ParticipantSemanticError>,
411        impacts: Vec<DispatchImpact>,
412    ) -> Self {
413        Self { result, impacts }
414    }
415
416    pub(crate) fn into_parts(self) -> (Result<(), ParticipantSemanticError>, Vec<DispatchImpact>) {
417        (self.result, self.impacts)
418    }
419
420    pub(crate) fn into_result(self) -> Result<(), ParticipantSemanticError> {
421        self.result
422    }
423}
424
425/// Server-owned adapter from a decoded request to a protocol-owned value.
426pub trait ParticipantSemanticHandler: core::fmt::Debug + Send + Sync {
427    /// Applies one already authenticated and capability-gated request.
428    ///
429    /// `conversations` is the receiving connection's semantic-conversation
430    /// dispatch map: the handler reads it for the crate's stage-6
431    /// connection-conversation capacity facts and installs a slot exactly
432    /// when an operation's capacity commit reports `newly_tracked`.
433    ///
434    /// # Errors
435    ///
436    /// Returns [`ParticipantSemanticError`] when no protocol value can be
437    /// produced. The caller closes rather than fabricating a response.
438    ///
439    /// Production handlers override this with the signed
440    /// `max_semantic_conversations_per_connection`; semantic-only fixtures own
441    /// no publication conversations.
442    ///
443    /// Returns the latched fatal, when participant service must remain stopped.
444    fn service_fatal(&self) -> Result<Option<ParticipantServiceFatal>, ParticipantSemanticError> {
445        Ok(None)
446    }
447
448    /// The subject `conversation_id` is placed under, for the access decision
449    /// a connection admitted on a Lys pass makes before every participant
450    /// request (the access matrix's `participant_binding`: a conversation's
451    /// subject is authority data, never a subject guessed from the numeric id).
452    ///
453    /// `None` means no authority maps this conversation to a subject, and the
454    /// request is refused by name. That is the default, and today every
455    /// handler answers it: the authority that places a participant conversation
456    /// under a subject is a named seam, not yet built. Connections admitted on
457    /// `WirePassV1` or a bearer never ask.
458    fn conversation_subject(&self, _conversation_id: ConversationId) -> Option<String> {
459        None
460    }
461
462    /// Atomically latches the post-Open fatal selected by Decision B.
463    ///
464    /// Implementations must preserve the first fatal and return it on every later call.
465    /// The default exists only for semantic fixtures which own no durable intents.
466    ///
467    /// # Errors
468    ///
469    /// Returns a semantic service error when the fatal latch cannot be inspected or updated.
470    fn latch_connection_fate_intent_incomplete(
471        &self,
472        open_sequence: u64,
473        conversation_id: ConversationId,
474    ) -> Result<ParticipantServiceFatal, ParticipantSemanticError> {
475        Ok(ParticipantServiceFatal::ConnectionFateIntentIncomplete {
476            open_sequence,
477            conversation_id,
478        })
479    }
480
481    /// Applies every matching participant binding named by one durable Open.
482    ///
483    /// The incarnation-stream lock is not held while this method runs. Each
484    /// implementation serializes conversations independently and must return
485    /// only after every source and immediately executable specific fate flushes.
486    ///
487    /// # Errors
488    ///
489    /// Returns a semantic failure without consuming the Open; startup or the
490    /// live fatal path retains it for exact replay.
491    fn handle_connection_fate(
492        &self,
493        work_item: ConnectionFateWorkItem,
494    ) -> Result<(), ParticipantSemanticError> {
495        drop(work_item);
496        Err(ParticipantSemanticError::Unavailable)
497    }
498
499    /// Applies connection fate while preserving every committed conversation's
500    /// post-flush dispatch effects on both success and failure exits.
501    fn handle_connection_fate_with_impact(
502        &self,
503        work_item: ConnectionFateWorkItem,
504    ) -> ParticipantConnectionFateOutcome {
505        ParticipantConnectionFateOutcome::unchanged(self.handle_connection_fate(work_item))
506    }
507
508    /// Drains every head of one conversation's immutable-candidate lane, as
509    /// boot recovery does, and reports the drains on the outcome's impact.
510    ///
511    /// A clean stop calls this for each conversation a parked connection fate
512    /// still waits on, so the fate can complete before the process exits
513    /// instead of leaving its Open for the next boot to reconcile. The default
514    /// owns no lane and answers [`ParticipantSemanticError::Unavailable`],
515    /// which the stop reports by name.
516    fn drain_candidate_lane_with_impact(
517        &self,
518        conversation_id: ConversationId,
519    ) -> ParticipantSemanticOutcome<()> {
520        let _ = conversation_id;
521        ParticipantSemanticOutcome::unchanged(Err(ParticipantSemanticError::Unavailable))
522    }
523
524    /// Installs the notifier told of every committed checkpoint crossing
525    /// ([`crate::server::connection::ConnectionNotifier::on_checkpoint_crossing`]).
526    /// The default keeps no checkpoints and tells no one.
527    fn install_checkpoint_crossing_observer(
528        &self,
529        observer: Arc<dyn crate::server::connection::ConnectionNotifier>,
530    ) {
531        drop(observer);
532    }
533
534    /// Repairs every remaining binding owned by a prior server incarnation.
535    ///
536    /// Startup calls this after all retained Opens complete and before publishing
537    /// the incarnation authority, scheduler, listener, or new admission.
538    ///
539    /// The default performs NO repair and reports success, so startup proceeds as
540    /// if there were nothing left over from the prior incarnation. That is correct
541    /// only for an implementor that holds no durable bindings. Any implementor with
542    /// real restart semantics MUST override this — inheriting the default leaves
543    /// stale bindings in place and says nothing about them.
544    ///
545    /// # Errors
546    ///
547    /// Returns a semantic failure while startup still owns all publication seams.
548    fn repair_unclean_server_restart(
549        &self,
550        current_server_incarnation: u64,
551    ) -> Result<(), ParticipantSemanticError> {
552        let _ = current_server_incarnation;
553        Ok(())
554    }
555
556    /// Reports whether any listed conversation currently contains a Bound slot
557    /// owned by this exact connection. Terminal decode funnels use this query to
558    /// distinguish bound-only `ProtocolError` from pre-auth/detached/internal paths.
559    ///
560    /// The default answers "unbound" unconditionally, without inspecting anything,
561    /// so every caller is told this connection owns no Bound slot. That is correct
562    /// only for an implementor that can never hold one. Any implementor with real
563    /// binding semantics MUST override this — inheriting the default silently
564    /// routes genuinely bound connections down the unbound funnel.
565    ///
566    /// # Errors
567    ///
568    /// Returns a semantic failure when exact bound authority cannot be inspected.
569    fn connection_has_bound_participant(
570        &self,
571        connection_incarnation: ConnectionIncarnation,
572        conversations: &[ConversationId],
573    ) -> Result<bool, ParticipantSemanticError> {
574        let _ = connection_incarnation;
575        let _ = conversations;
576        Ok(false)
577    }
578
579    fn publication_conversation_limit(&self) -> u64 {
580        0
581    }
582
583    /// Resolves all live current bindings with pending durable obligations for
584    /// one conversation. Production overrides this; semantic-only fixtures have
585    /// no publication source.
586    ///
587    /// # Errors
588    ///
589    /// Returns a semantic fault when durable readiness cannot be resolved.
590    fn ready_connection_incarnations(
591        &self,
592        _conversation_id: ConversationId,
593    ) -> Result<Vec<ConnectionIncarnation>, ParticipantSemanticError> {
594        Ok(Vec::new())
595    }
596
597    /// Selects the least durable recipient obligation for this incarnation,
598    /// restarting from durable ack when `offered` names an older binding.
599    ///
600    /// # Errors
601    ///
602    /// Returns a semantic fault when the durable obligation owner is unavailable.
603    fn next_publication(
604        &self,
605        _connection_incarnation: ConnectionIncarnation,
606        _conversation_id: ConversationId,
607        _offered: Option<ParticipantOfferedProgress>,
608    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
609        Ok(None)
610    }
611
612    /// Checks that a held head still belongs to the exact current binding before
613    /// it is offered after writable readiness.
614    ///
615    /// # Errors
616    ///
617    /// Returns a semantic fault when current binding authority cannot be read.
618    fn publication_binding_is_current(
619        &self,
620        _conversation_id: ConversationId,
621        _participant_id: ParticipantId,
622        _binding_epoch: BindingEpoch,
623    ) -> Result<bool, ParticipantSemanticError> {
624        Ok(false)
625    }
626
627    /// Re-selects a held publication against current binding, cursor, debt, and
628    /// outbox authority before its first offer. Semantic-only handlers retain
629    /// the binding-only default; production overrides this with the full locked
630    /// dispatch decision.
631    ///
632    /// # Errors
633    ///
634    /// Returns a semantic fault when current publication authority cannot be read.
635    fn publication_is_current(
636        &self,
637        publication: &ParticipantPublication,
638        offered: Option<ParticipantOfferedProgress>,
639    ) -> Result<bool, ParticipantSemanticError> {
640        if offered.is_some_and(|progress| progress.binding_epoch != publication.binding_epoch) {
641            return Ok(false);
642        }
643        self.publication_binding_is_current(
644            publication.conversation_id(),
645            publication.participant_id,
646            publication.binding_epoch,
647        )
648    }
649
650    /// Records exact successful marker enqueue testimony. Non-marker offers are
651    /// ignored by production after validating their current binding.
652    ///
653    /// # Errors
654    ///
655    /// Returns a semantic fault when exact offer testimony cannot be recorded.
656    fn record_publication_offer(
657        &self,
658        _publication: &ParticipantPublication,
659    ) -> Result<(), ParticipantSemanticError> {
660        Ok(())
661    }
662
663    /// Applies observer recovery with the weak exact-live-connection target
664    /// captured by the installed service. Semantic-only handlers delegate to
665    /// their ordinary request path and do not own observer publication.
666    ///
667    /// # Errors
668    ///
669    /// Returns [`ParticipantSemanticError`] under the same contract as
670    /// [`Self::handle`].
671    fn handle_observer_recovery(
672        &self,
673        context: ParticipantConnectionContext,
674        conversations: &mut ParticipantConnectionConversations,
675        request: ObserverRecoveryHandshake,
676        target: Option<ObserverPublicationTarget>,
677    ) -> Result<ServerValue, ParticipantSemanticError> {
678        drop(target);
679        self.handle(
680            context,
681            conversations,
682            ClientRequest::ObserverRecovery(request),
683        )
684    }
685
686    /// Applies one request and preserves post-commit effects on every exit.
687    ///
688    /// Semantic-only fixtures default to an empty accumulator. Production
689    /// overrides this boundary and returns operation-owned effects.
690    fn handle_with_impact(
691        &self,
692        context: ParticipantConnectionContext,
693        conversations: &mut ParticipantConnectionConversations,
694        request: ClientRequest,
695    ) -> ParticipantSemanticOutcome<ServerValue> {
696        ParticipantSemanticOutcome::unchanged(self.handle(context, conversations, request))
697    }
698
699    /// Applies one decoded participant request to protocol-owned authority.
700    ///
701    /// # Errors
702    ///
703    /// Returns [`ParticipantSemanticError`] when durable or protocol authority
704    /// cannot produce a truthful terminal value. The connection fails rather
705    /// than fabricating a response.
706    fn handle(
707        &self,
708        context: ParticipantConnectionContext,
709        conversations: &mut ParticipantConnectionConversations,
710        request: ClientRequest,
711    ) -> Result<ServerValue, ParticipantSemanticError>;
712}
713
714/// Server-sealed participant activation token installed on a connection
715/// supervisor.
716///
717/// The semantic handler and its durable store form one value so participant
718/// capability activation cannot observe one without the other. The supervisor
719/// uses the store to durably allocate connection incarnations before spawning a
720/// connection process, and the process uses the handler only after that exact
721/// incarnation has been carried into its state. The token atomically carries the
722/// pair declared by server composition; it does not independently prove storage
723/// namespace identity.
724///
725/// Construction and access are server-private. Until a complete production
726/// lifecycle handler exists, external [`ConnectionServices`](crate::server::connection::ConnectionServices)
727/// implementations cannot manufacture an activation token or advertise the
728/// participant capability.
729#[derive(Clone, Debug)]
730pub struct InstalledParticipantService {
731    handler: Arc<dyn ParticipantSemanticHandler>,
732    durable_store: Arc<dyn DurableStore>,
733    frame_limit: ValidatedFrameLimit,
734    publication_registry: Arc<ParticipantPublicationRegistry>,
735    /// Connections refused `MarkerSettlementBackpressure` in this process
736    /// lifetime, by conversation. See [`MarkerSettlementWaiter`].
737    settlement_waiters: Arc<
738        std::sync::Mutex<std::collections::BTreeMap<ConversationId, Vec<MarkerSettlementWaiter>>>,
739    >,
740    /// The connection runtime told about every candidate-lane drain. Weak so
741    /// the service never keeps the runtime that owns it alive.
742    candidate_lane_listener: Arc<std::sync::Mutex<Option<Weak<dyn CandidateLaneListener>>>>,
743}
744
745impl InstalledParticipantService {
746    /// Pairs a semantic handler, its declared durable store, and the raw
747    /// configured participant wire-frame limit.
748    ///
749    /// Production construction happens exactly once, in the server's
750    /// connection-services layer, from the deployment's `[participant]`
751    /// configuration; tests construct it directly with fixture handlers.
752    ///
753    /// # Errors
754    ///
755    /// Returns the shared codec error when the configured limit is smaller than
756    /// the protocol's minimum complete frame.
757    pub(crate) fn new(
758        handler: Arc<dyn ParticipantSemanticHandler>,
759        durable_store: Arc<dyn DurableStore>,
760        configured_wf: u64,
761    ) -> Result<Self, CodecError> {
762        Ok(Self {
763            handler,
764            durable_store,
765            frame_limit: normalize_configured_frame_limit(configured_wf)?,
766            publication_registry: Arc::new(ParticipantPublicationRegistry::default()),
767            settlement_waiters: Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new())),
768            candidate_lane_listener: Arc::new(std::sync::Mutex::new(None)),
769        })
770    }
771
772    /// Installs the notifier told of every committed checkpoint crossing.
773    pub(crate) fn install_checkpoint_crossing_observer(
774        &self,
775        observer: Arc<dyn crate::server::connection::ConnectionNotifier>,
776    ) {
777        self.handler.install_checkpoint_crossing_observer(observer);
778    }
779
780    /// Installs the runtime told about candidate-lane drains, replacing any
781    /// earlier one.
782    pub(crate) fn install_candidate_lane_listener(
783        &self,
784        listener: Weak<dyn CandidateLaneListener>,
785    ) -> Result<(), ParticipantSemanticError> {
786        let mut installed = self.candidate_lane_listener.lock().map_err(|_| {
787            ParticipantSemanticError::Internal {
788                message: "candidate-lane listener slot is poisoned".to_owned(),
789            }
790        })?;
791        *installed = Some(listener);
792        drop(installed);
793        Ok(())
794    }
795
796    /// Drains one conversation's candidate lane and tells the listener, as a
797    /// publish's drain would.
798    ///
799    /// # Errors
800    ///
801    /// Returns the handler's drain refusal after telling every drain it
802    /// committed first.
803    pub(crate) fn drain_candidate_lane(
804        &self,
805        conversation_id: ConversationId,
806    ) -> Result<(), ParticipantSemanticError> {
807        let outcome = self
808            .handler
809            .drain_candidate_lane_with_impact(conversation_id);
810        let (result, impact) = outcome.into_parts();
811        self.notify_impact(&impact)?;
812        result
813    }
814
815    /// Tells the installed listener that `conversation_id`'s lane drained.
816    ///
817    /// The listener is called with the slot lock released, because it retries
818    /// durable connection fates.
819    fn tell_candidate_lane_drained(
820        &self,
821        conversation_id: ConversationId,
822    ) -> Result<(), ParticipantSemanticError> {
823        let listener = self
824            .candidate_lane_listener
825            .lock()
826            .map_err(|_| ParticipantSemanticError::Internal {
827                message: "candidate-lane listener slot is poisoned".to_owned(),
828            })?
829            .as_ref()
830            .and_then(Weak::upgrade);
831        if let Some(listener) = listener {
832            listener.candidate_lane_drained(conversation_id);
833        }
834        Ok(())
835    }
836
837    /// Clones the durable store used by the installed participant service.
838    #[must_use]
839    pub(crate) fn durable_store(&self) -> Arc<dyn DurableStore> {
840        Arc::clone(&self.durable_store)
841    }
842
843    /// Returns the normalized configured complete-frame limit advertised by
844    /// this installed participant service.
845    #[must_use]
846    pub(crate) const fn frame_limit(&self) -> ValidatedFrameLimit {
847        self.frame_limit
848    }
849
850    /// Returns the signed semantic-conversation allowance shared by publication
851    /// readiness and connection-held encoded heads.
852    #[must_use]
853    pub(crate) fn publication_conversation_limit(&self) -> u64 {
854        self.handler.publication_conversation_limit()
855    }
856
857    /// Creates the strongly connection-owned ready inbox at process spawn.
858    #[must_use]
859    pub(crate) fn new_publication_inbox(&self) -> ParticipantPublicationInbox {
860        ParticipantPublicationInbox::new(self.handler.publication_conversation_limit())
861    }
862
863    /// Returns the shared weak publication registry.
864    #[must_use]
865    pub(crate) fn publication_registry(&self) -> &ParticipantPublicationRegistry {
866        &self.publication_registry
867    }
868
869    /// Selects one exact durable publication through the installed production
870    /// source.
871    pub(crate) fn next_publication(
872        &self,
873        connection_incarnation: ConnectionIncarnation,
874        conversation_id: ConversationId,
875        offered: Option<ParticipantOfferedProgress>,
876    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
877        self.handler
878            .next_publication(connection_incarnation, conversation_id, offered)
879    }
880
881    pub(crate) fn publication_is_current(
882        &self,
883        publication: &ParticipantPublication,
884        offered: Option<ParticipantOfferedProgress>,
885    ) -> Result<bool, ParticipantSemanticError> {
886        self.handler.publication_is_current(publication, offered)
887    }
888
889    pub(crate) fn record_publication_offer(
890        &self,
891        publication: &ParticipantPublication,
892    ) -> Result<(), ParticipantSemanticError> {
893        self.handler.record_publication_offer(publication)
894    }
895
896    /// Installs the waiter a `MarkerSettlementBackpressure` refusal earns.
897    ///
898    /// The match is the scope: only that ONE `ServerValue` variant registers,
899    /// so an enrollment-wrapper refusal (which carries no epoch and no wake) and
900    /// every other refusal on the same arm install nothing. Re-refusing the same
901    /// conversation on the same connection REPLACES the waiter, because a
902    /// connection waits on its most recent refusal and never on two epochs at
903    /// once.
904    fn register_settlement_waiter(
905        &self,
906        context: ParticipantConnectionContext,
907        value: &ServerValue,
908    ) -> Result<(), ParticipantSemanticError> {
909        let ServerValue::MarkerSettlementBackpressure(refusal) = value else {
910            return Ok(());
911        };
912        let (conversation_id, refused_epoch) = match *refusal {
913            liminal_protocol::wire::MarkerSettlementBackpressure::CredentialAttach {
914                conversation_id,
915                refused_epoch,
916            }
917            | liminal_protocol::wire::MarkerSettlementBackpressure::Detach {
918                conversation_id,
919                refused_epoch,
920            } => (conversation_id, refused_epoch),
921        };
922        let incarnation = context.connection_incarnation();
923        let Some(target) = self
924            .publication_registry
925            .observer_target(incarnation)
926            .map_err(|error| ParticipantSemanticError::Internal {
927                message: format!("settlement publication target failed: {error}"),
928            })?
929        else {
930            return Ok(());
931        };
932        let mut waiters =
933            self.settlement_waiters
934                .lock()
935                .map_err(|_| ParticipantSemanticError::Internal {
936                    message: "settlement waiter registry is poisoned".to_owned(),
937                })?;
938        let entry = waiters.entry(conversation_id).or_default();
939        entry.retain(|waiter| waiter.connection_incarnation != incarnation);
940        entry.push(MarkerSettlementWaiter {
941            connection_incarnation: incarnation,
942            refused_epoch,
943            target,
944        });
945        drop(waiters);
946        Ok(())
947    }
948
949    /// Test hook onto [`Self::register_settlement_waiter`].
950    ///
951    /// The registry §0.16 obligation 3 rests on is private and is exercised in
952    /// production only as a side effect of a refusal travelling out of `handle`.
953    /// A pin that can only reach it that way measures the refusal path and the
954    /// registry at once, and cannot tell which of the two is scoping the wake.
955    /// These two hooks let the registry be measured ALONE.
956    #[cfg(test)]
957    pub(super) fn register_settlement_waiter_for_test(
958        &self,
959        context: ParticipantConnectionContext,
960        value: &ServerValue,
961    ) -> Result<(), ParticipantSemanticError> {
962        self.register_settlement_waiter(context, value)
963    }
964
965    /// Test hook onto [`Self::fire_settlements`].
966    #[cfg(test)]
967    pub(super) fn fire_settlements_for_test(
968        &self,
969        conversation_id: ConversationId,
970        settled_epochs: &[u64],
971    ) -> Result<(), ParticipantSemanticError> {
972        self.fire_settlements(conversation_id, settled_epochs)
973    }
974
975    /// Number of waiters currently installed for one conversation.
976    #[cfg(test)]
977    pub(super) fn settlement_waiter_count(&self, conversation_id: ConversationId) -> usize {
978        self.settlement_waiters.lock().map_or(0, |waiters| {
979            waiters.get(&conversation_id).map_or(0, Vec::len)
980        })
981    }
982
983    /// Fires the settlement wake for every connection whose OWN refusal named
984    /// this exact epoch, and for no one else.
985    ///
986    /// Epoch equality is load-bearing: it is what the stage-11 retry discipline
987    /// matches on, and it is also what keeps a connection waiting on a later
988    /// candidate from being told its own wait is over. Waiters are one-shot —
989    /// fired or dead, they leave the registry.
990    fn fire_settlements(
991        &self,
992        conversation_id: ConversationId,
993        settled_epochs: &[u64],
994    ) -> Result<(), ParticipantSemanticError> {
995        if settled_epochs.is_empty() {
996            return Ok(());
997        }
998        let mut waiters =
999            self.settlement_waiters
1000                .lock()
1001                .map_err(|_| ParticipantSemanticError::Internal {
1002                    message: "settlement waiter registry is poisoned".to_owned(),
1003                })?;
1004        let Some(entry) = waiters.get_mut(&conversation_id) else {
1005            return Ok(());
1006        };
1007        let mut fired = Vec::new();
1008        entry.retain(|waiter| {
1009            if settled_epochs.contains(&waiter.refused_epoch) {
1010                fired.push((waiter.target.clone(), waiter.refused_epoch));
1011                false
1012            } else {
1013                true
1014            }
1015        });
1016        if entry.is_empty() {
1017            waiters.remove(&conversation_id);
1018        }
1019        drop(waiters);
1020        for (target, refused_epoch) in fired {
1021            target
1022                .publish_marker_settled(MarkerSettledPublication {
1023                    conversation_id,
1024                    refused_epoch,
1025                })
1026                .map_err(|error| ParticipantSemanticError::Internal {
1027                    message: format!("marker settled publication failed: {error}"),
1028                })?;
1029        }
1030        Ok(())
1031    }
1032
1033    fn notify_impact(&self, impact: &DispatchImpact) -> Result<(), ParticipantSemanticError> {
1034        let Some(conversation_id) = impact.conversation_id() else {
1035            return Ok(());
1036        };
1037        if impact.candidate_lane_drained() {
1038            self.tell_candidate_lane_drained(conversation_id)?;
1039        }
1040        self.fire_settlements(conversation_id, impact.settled_epochs())?;
1041        for target in impact.target_union() {
1042            self.publication_registry
1043                .notify(
1044                    target.binding_epoch().connection_incarnation,
1045                    conversation_id,
1046                )
1047                .map_err(|error| ParticipantSemanticError::Internal {
1048                    message: format!("participant publication wake failed: {error}"),
1049                })?;
1050        }
1051        Ok(())
1052    }
1053}
1054
1055impl ParticipantSemanticHandler for InstalledParticipantService {
1056    fn service_fatal(&self) -> Result<Option<ParticipantServiceFatal>, ParticipantSemanticError> {
1057        self.handler.service_fatal()
1058    }
1059
1060    fn conversation_subject(&self, conversation_id: ConversationId) -> Option<String> {
1061        self.handler.conversation_subject(conversation_id)
1062    }
1063
1064    fn latch_connection_fate_intent_incomplete(
1065        &self,
1066        open_sequence: u64,
1067        conversation_id: ConversationId,
1068    ) -> Result<ParticipantServiceFatal, ParticipantSemanticError> {
1069        self.handler
1070            .latch_connection_fate_intent_incomplete(open_sequence, conversation_id)
1071    }
1072
1073    fn publication_conversation_limit(&self) -> u64 {
1074        self.handler.publication_conversation_limit()
1075    }
1076
1077    fn handle_connection_fate(
1078        &self,
1079        work_item: ConnectionFateWorkItem,
1080    ) -> Result<(), ParticipantSemanticError> {
1081        let outcome = self.handler.handle_connection_fate_with_impact(work_item);
1082        let (result, impacts) = outcome.into_parts();
1083        for impact in &impacts {
1084            self.notify_impact(impact)?;
1085        }
1086        result
1087    }
1088
1089    fn handle_connection_fate_with_impact(
1090        &self,
1091        work_item: ConnectionFateWorkItem,
1092    ) -> ParticipantConnectionFateOutcome {
1093        let result = self.handle_connection_fate(work_item);
1094        ParticipantConnectionFateOutcome::unchanged(result)
1095    }
1096
1097    fn drain_candidate_lane_with_impact(
1098        &self,
1099        conversation_id: ConversationId,
1100    ) -> ParticipantSemanticOutcome<()> {
1101        ParticipantSemanticOutcome::unchanged(self.drain_candidate_lane(conversation_id))
1102    }
1103
1104    fn repair_unclean_server_restart(
1105        &self,
1106        current_server_incarnation: u64,
1107    ) -> Result<(), ParticipantSemanticError> {
1108        self.handler
1109            .repair_unclean_server_restart(current_server_incarnation)
1110    }
1111
1112    fn connection_has_bound_participant(
1113        &self,
1114        connection_incarnation: ConnectionIncarnation,
1115        conversations: &[ConversationId],
1116    ) -> Result<bool, ParticipantSemanticError> {
1117        self.handler
1118            .connection_has_bound_participant(connection_incarnation, conversations)
1119    }
1120
1121    fn handle(
1122        &self,
1123        context: ParticipantConnectionContext,
1124        conversations: &mut ParticipantConnectionConversations,
1125        request: ClientRequest,
1126    ) -> Result<ServerValue, ParticipantSemanticError> {
1127        if let ClientRequest::ObserverRecovery(request) = request {
1128            let target = self
1129                .publication_registry
1130                .observer_target(context.connection_incarnation())
1131                .map_err(|error| ParticipantSemanticError::Internal {
1132                    message: format!("observer publication target failed: {error}"),
1133                })?;
1134            return self
1135                .handler
1136                .handle_observer_recovery(context, conversations, request, target);
1137        }
1138        let outcome = self
1139            .handler
1140            .handle_with_impact(context, conversations, request);
1141        let (result, impact) = outcome.into_parts();
1142        // Fire before registering: a request that both drained and was refused
1143        // must not wake itself with its own drain, and within one request the
1144        // settlement it cleared is never the one it is now waiting on.
1145        self.notify_impact(&impact)?;
1146        if let Ok(value) = &result {
1147            self.register_settlement_waiter(context, value)?;
1148            self.settle_refused_marker(value)?;
1149        }
1150        result
1151    }
1152}
1153
1154/// Result of dispatching one generic frame through participant transport.
1155#[derive(Debug)]
1156pub enum ParticipantDispatch {
1157    /// The generic frame belongs to another protocol.
1158    NotParticipant,
1159    /// Exact encoded response selected by the shared gate or semantic handler.
1160    Respond(Frame),
1161    /// Exact crate-owned pre-semantic rejection, followed by connection close.
1162    RespondThenClose(Frame),
1163    /// No truthful participant response exists; the connection must fail closed.
1164    Fatal(ParticipantDispatchError),
1165}
1166
1167/// Failure after a generic frame has entered participant dispatch.
1168#[derive(Debug, thiserror::Error)]
1169pub enum ParticipantDispatchError {
1170    /// The preserved generic frame could not represent a canonical participant frame.
1171    #[error("invalid generic participant frame")]
1172    InvalidGenericFrame,
1173    /// The semantic handler could not produce a protocol value.
1174    #[error(transparent)]
1175    Semantic(#[from] ParticipantSemanticError),
1176    /// The crate-produced value could not be encoded into the generic transport.
1177    #[error("failed to encode participant response: {0:?}")]
1178    Encode(CodecError),
1179}
1180
1181fn pass_refusal(
1182    principal: Option<&PassPrincipal>,
1183    request: &liminal_protocol::wire::ClientRequest,
1184) -> Option<liminal_protocol::wire::TransportRejectionReason> {
1185    let principal = principal?;
1186    match request {
1187        liminal_protocol::wire::ClientRequest::Enrollment(request) if !principal.may_enroll => Some(liminal_protocol::wire::TransportRejectionReason::EnrollmentNotPermitted),
1188        liminal_protocol::wire::ClientRequest::Enrollment(request) if !principal.conversations.contains(&request.conversation_id) => Some(liminal_protocol::wire::TransportRejectionReason::EnrollmentConversationOutOfScope),
1189        liminal_protocol::wire::ClientRequest::CredentialAttach(request) if !principal.conversations.contains(&request.conversation_id) => Some(liminal_protocol::wire::TransportRejectionReason::CredentialAttachConversationOutOfScope),
1190        _ => None,
1191    }
1192}
1193
1194/// The write-outcome-unknown value (`0x0128`) answering `request`, or `None`
1195/// for a request that carries no attempt token to be re-presented with.
1196const fn write_outcome_unknown(
1197    request: &liminal_protocol::wire::ClientRequest,
1198) -> Option<ServerValue> {
1199    use liminal_protocol::wire::{
1200        AttachEnvelope, ClientRequest, DetachEnvelope, EnrollmentEnvelope, LeaveEnvelope,
1201        RecordAdmissionEnvelope, WriteOutcomeUnknown,
1202    };
1203    Some(ServerValue::WriteOutcomeUnknown(match request {
1204        ClientRequest::Enrollment(request) => WriteOutcomeUnknown::Enrollment {
1205            request: EnrollmentEnvelope {
1206                conversation_id: request.conversation_id,
1207                enrollment_token: request.enrollment_token,
1208            },
1209        },
1210        ClientRequest::CredentialAttach(request) => WriteOutcomeUnknown::CredentialAttach {
1211            request: AttachEnvelope {
1212                conversation_id: request.conversation_id,
1213                participant_id: request.participant_id,
1214                capability_generation: request.capability_generation,
1215                attach_attempt_token: request.attach_attempt_token,
1216                accept_marker_delivery_seq: request.accept_marker_delivery_seq,
1217            },
1218        },
1219        ClientRequest::Detach(request) => WriteOutcomeUnknown::Detach {
1220            request: DetachEnvelope {
1221                conversation_id: request.conversation_id,
1222                participant_id: request.participant_id,
1223                capability_generation: request.capability_generation,
1224                detach_attempt_token: request.detach_attempt_token,
1225            },
1226        },
1227        ClientRequest::Leave(request) => WriteOutcomeUnknown::Leave {
1228            request: LeaveEnvelope {
1229                conversation_id: request.conversation_id,
1230                participant_id: request.participant_id,
1231                capability_generation: request.capability_generation,
1232                leave_attempt_token: request.leave_attempt_token,
1233            },
1234        },
1235        ClientRequest::RecordAdmission(request) => WriteOutcomeUnknown::RecordAdmission {
1236            request: RecordAdmissionEnvelope {
1237                conversation_id: request.conversation_id,
1238                participant_id: request.participant_id,
1239                capability_generation: request.capability_generation,
1240                record_admission_attempt_token: request.record_admission_attempt_token,
1241            },
1242        },
1243        _ => return None,
1244    }))
1245}
1246
1247/// Gates, decodes, semantically applies, and encodes one participant frame.
1248///
1249/// Transport rejection values originate in `liminal-protocol`; semantic values
1250/// originate only in `handler`. No lifecycle outcome is constructed here.
1251#[must_use]
1252pub fn dispatch_generic_frame(
1253    frame: &Frame,
1254    authenticated: bool,
1255    session: ParticipantSession,
1256    context: ParticipantConnectionContext,
1257    pass_principal: Option<&PassPrincipal>,
1258    conversations: &mut ParticipantConnectionConversations,
1259    handler: &dyn ParticipantSemanticHandler,
1260) -> ParticipantDispatch {
1261    let (value, close_after_response) = match gate_generic_frame(frame, authenticated, session) {
1262        ParticipantIngress::NotParticipant => return ParticipantDispatch::NotParticipant,
1263        ParticipantIngress::Rejected(rejection) => {
1264            (ServerValue::ParticipantTransportRejected(rejection), true)
1265        }
1266        ParticipantIngress::InvalidGenericFrame => {
1267            return ParticipantDispatch::Fatal(ParticipantDispatchError::InvalidGenericFrame);
1268        }
1269        ParticipantIngress::Request(request) => {
1270            if let Some(reason) = pass_refusal(pass_principal, &request) {
1271                (
1272                    ServerValue::ParticipantTransportRejected(
1273                        liminal_protocol::wire::ParticipantTransportRejected { reason },
1274                    ),
1275                    false,
1276                )
1277            } else {
1278                let outcome_unknown = write_outcome_unknown(&request);
1279                match handler.handle(context, conversations, request) {
1280                    Ok(value) => (value, false),
1281                    Err(error) => match (&error, outcome_unknown) {
1282                        (
1283                            ParticipantSemanticError::WriteOutcomeUnknown { message },
1284                            Some(value),
1285                        ) => {
1286                            tracing::warn!(
1287                                %message,
1288                                "a participant write's outcome is unknown; the client is told so \
1289                                 and re-presents the request with its token"
1290                            );
1291                            (value, false)
1292                        }
1293                        _ => {
1294                            // DUR-024 R3: a refused checkpoint publication
1295                            // never arrives here. The handler answers the
1296                            // crossing's own write as a live commit, marks
1297                            // the checkpoint owed and retries at the next
1298                            // crossing; the door's 91 minutes of 10 October
1299                            // began with this arm tearing every connection
1300                            // down over one refused publication.
1301                            return ParticipantDispatch::Fatal(ParticipantDispatchError::Semantic(
1302                                error,
1303                            ));
1304                        }
1305                    },
1306                }
1307            }
1308        }
1309    };
1310    match encode_server_value(value) {
1311        Ok(frame) if close_after_response => ParticipantDispatch::RespondThenClose(frame),
1312        Ok(frame) => ParticipantDispatch::Respond(frame),
1313        Err(error) => ParticipantDispatch::Fatal(ParticipantDispatchError::Encode(error)),
1314    }
1315}