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;
9
10use liminal::durability::DurableStore;
11use liminal::protocol::Frame;
12use liminal_protocol::lifecycle::ConnectionConversationTracking;
13use liminal_protocol::wire::{
14    BindingEpoch, ClientRequest, CodecError, ConnectionIncarnation, ConversationId,
15    ObserverRecoveryHandshake, ParticipantId, ServerValue, ValidatedFrameLimit,
16};
17
18use super::dispatch_impact::DispatchImpact;
19use super::transport::{
20    ParticipantIngress, ParticipantSession, encode_server_value, gate_generic_frame,
21    normalize_configured_frame_limit,
22};
23use super::{
24    ObserverPublicationTarget, ParticipantOfferedProgress, ParticipantPublication,
25    ParticipantPublicationInbox, ParticipantPublicationRegistry,
26};
27
28/// Connection-local semantic-conversation dispatch map (contract R-D1: the
29/// connection's binding/interest/dispatch maps are bounded by the signed
30/// `max_semantic_conversations_per_connection`).
31///
32/// One value lives in each connection process's state for the connection's
33/// lifetime and is dropped with it. A conversation enters the map exactly
34/// when a semantic operation for it COMMITS on this connection (the crate's
35/// `ConnectionConversationCapacityCommit::newly_tracked` verdict) or when an
36/// observer-recovery batch arms its refusal-only recipient; refusals and
37/// replays leave the map untouched, exactly as the crate's stage-6 selector
38/// leaves its counter unchanged. Growth is therefore bounded by the signed
39/// limit the stage-6 selector enforces.
40#[derive(Debug, Default)]
41pub struct ParticipantConnectionConversations {
42    tracked: BTreeSet<ConversationId>,
43}
44
45impl ParticipantConnectionConversations {
46    /// Stage-6 tracking fact for one conversation on this connection.
47    #[must_use]
48    pub fn tracking(&self, conversation_id: ConversationId) -> ConnectionConversationTracking {
49        if self.tracked.contains(&conversation_id) {
50            ConnectionConversationTracking::AlreadyTracked
51        } else {
52            ConnectionConversationTracking::Untracked
53        }
54    }
55
56    /// Current connection-conversation occupancy.
57    #[must_use]
58    pub fn occupied(&self) -> u64 {
59        // `usize` fits `u64` on every supported target; if that ever stopped
60        // holding, saturating at MAX fails CLOSED (capacity reads as full)
61        // rather than silently under-counting occupancy.
62        u64::try_from(self.tracked.len()).unwrap_or(u64::MAX)
63    }
64
65    /// Installs one conversation slot after a capacity-committing operation.
66    pub fn track(&mut self, conversation_id: ConversationId) {
67        self.tracked.insert(conversation_id);
68    }
69
70    /// Sorted tracked conversations (the observer-recovery preflight's
71    /// current-occupancy input).
72    #[must_use]
73    pub fn tracked_conversations(&self) -> Vec<ConversationId> {
74        self.tracked.iter().copied().collect()
75    }
76}
77
78/// Connection-scoped authority facts supplied to participant semantics.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct ParticipantConnectionContext {
81    connection_incarnation: ConnectionIncarnation,
82}
83
84impl ParticipantConnectionContext {
85    /// Captures the durably allocated incarnation of the receiving connection.
86    #[must_use]
87    pub const fn new(connection_incarnation: ConnectionIncarnation) -> Self {
88        Self {
89            connection_incarnation,
90        }
91    }
92
93    /// Returns the durably allocated receiving-connection incarnation.
94    #[must_use]
95    pub const fn connection_incarnation(self) -> ConnectionIncarnation {
96        self.connection_incarnation
97    }
98}
99
100/// Exact terminal classification preserved from a connection's close trigger.
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum ConnectionFateClass {
103    /// A protocol-level clean Disconnect.
104    CleanDisconnect,
105    /// An orderly server `ForceClose`.
106    ServerShutdown,
107    /// EOF or transport loss without clean protocol evidence.
108    ConnectionLost,
109    /// A terminal protocol/decode refusal after participant binding.
110    ProtocolError,
111}
112
113/// One durable bounded connection-fate intent delivered to participant semantics.
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct ConnectionFateWorkItem {
116    /// Durable incarnation-stream Open sequence used by participant source rows.
117    pub open_sequence: u64,
118    /// Exact connection whose current Bound slots are eligible.
119    pub connection_incarnation: ConnectionIncarnation,
120    /// Preserved close classification.
121    pub class: ConnectionFateClass,
122    /// Canonical sorted tracked-conversation snapshot owned by the Open.
123    pub tracked_conversations: Vec<ConversationId>,
124}
125
126/// Process-wide terminal participant-service latch.
127#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
128pub enum ParticipantServiceFatal {
129    /// A durable Open landed but one listed conversation could not durably finish its fate.
130    #[error(
131        "connection-fate intent {open_sequence} is incomplete at conversation {conversation_id}"
132    )]
133    ConnectionFateIntentIncomplete {
134        /// Durable incarnation-stream Open sequence.
135        open_sequence: u64,
136        /// Exact conversation whose non-idempotent completion failed.
137        conversation_id: ConversationId,
138    },
139}
140
141/// Non-wire semantic service failure.
142///
143/// A failure is terminal to the connection attempt. It is deliberately not
144/// convertible to [`ServerValue`], preventing the server from inventing a
145/// lifecycle response when the protocol-owned transition did not produce one.
146#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
147pub enum ParticipantSemanticError {
148    /// The complete semantic service is not installed.
149    #[error("participant semantic service is unavailable")]
150    Unavailable,
151    /// Durable state or a protocol invariant prevented semantic completion.
152    #[error("participant semantic service failed: {message}")]
153    Internal {
154        /// Diagnostic text for server logs; never placed on the participant wire.
155        message: String,
156    },
157    /// A process-wide participant fatal has already latched.
158    #[error(transparent)]
159    ServiceFatal(ParticipantServiceFatal),
160}
161
162/// One semantic result paired with every dispatch effect durably installed by
163/// the request before it returned.
164///
165/// The envelope deliberately owns the `Result`: a marker-drain prefix can
166/// commit before a later retry fails, and that failure must not erase the
167/// prefix's post-commit tell.
168#[derive(Debug)]
169pub struct ParticipantSemanticOutcome<T> {
170    result: Result<T, ParticipantSemanticError>,
171    impact: DispatchImpact,
172}
173
174impl<T> ParticipantSemanticOutcome<T> {
175    /// Wraps a fixture or operation which installed no dispatch effect.
176    #[must_use]
177    pub const fn unchanged(result: Result<T, ParticipantSemanticError>) -> Self {
178        Self {
179            result,
180            impact: DispatchImpact::Unchanged,
181        }
182    }
183
184    /// Carries an operation result and its complete request accumulator.
185    #[must_use]
186    pub const fn new(result: Result<T, ParticipantSemanticError>, impact: DispatchImpact) -> Self {
187        Self { result, impact }
188    }
189
190    pub(crate) fn into_parts(self) -> (Result<T, ParticipantSemanticError>, DispatchImpact) {
191        (self.result, self.impact)
192    }
193
194    /// Returns the semantic result when an internal caller has no notification
195    /// boundary. Production request dispatch uses the complete envelope; this
196    /// projection exists for the trait's legacy direct-call entry point.
197    pub(crate) fn into_result(self) -> Result<T, ParticipantSemanticError> {
198        self.result
199    }
200}
201
202/// One connection-fate result paired with every conversation impact committed
203/// before the fate operation returned.
204#[derive(Debug)]
205pub struct ParticipantConnectionFateOutcome {
206    result: Result<(), ParticipantSemanticError>,
207    impacts: Vec<DispatchImpact>,
208}
209
210impl ParticipantConnectionFateOutcome {
211    /// Wraps a fixture fate handler which committed no dispatch impact.
212    #[must_use]
213    pub const fn unchanged(result: Result<(), ParticipantSemanticError>) -> Self {
214        Self {
215            result,
216            impacts: Vec::new(),
217        }
218    }
219
220    /// Carries a fate result and every committed per-conversation impact.
221    #[must_use]
222    pub const fn new(
223        result: Result<(), ParticipantSemanticError>,
224        impacts: Vec<DispatchImpact>,
225    ) -> Self {
226        Self { result, impacts }
227    }
228
229    pub(crate) fn into_parts(self) -> (Result<(), ParticipantSemanticError>, Vec<DispatchImpact>) {
230        (self.result, self.impacts)
231    }
232
233    pub(crate) fn into_result(self) -> Result<(), ParticipantSemanticError> {
234        self.result
235    }
236}
237
238/// Server-owned adapter from a decoded request to a protocol-owned value.
239pub trait ParticipantSemanticHandler: core::fmt::Debug + Send + Sync {
240    /// Applies one already authenticated and capability-gated request.
241    ///
242    /// `conversations` is the receiving connection's semantic-conversation
243    /// dispatch map: the handler reads it for the crate's stage-6
244    /// connection-conversation capacity facts and installs a slot exactly
245    /// when an operation's capacity commit reports `newly_tracked`.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`ParticipantSemanticError`] when no protocol value can be
250    /// produced. The caller closes rather than fabricating a response.
251    ///
252    /// Production handlers override this with the signed
253    /// `max_semantic_conversations_per_connection`; semantic-only fixtures own
254    /// no publication conversations.
255    ///
256    /// Returns the latched fatal, when participant service must remain stopped.
257    fn service_fatal(&self) -> Result<Option<ParticipantServiceFatal>, ParticipantSemanticError> {
258        Ok(None)
259    }
260
261    /// Atomically latches the post-Open fatal selected by Decision B.
262    ///
263    /// Implementations must preserve the first fatal and return it on every later call.
264    /// The default exists only for semantic fixtures which own no durable intents.
265    ///
266    /// # Errors
267    ///
268    /// Returns a semantic service error when the fatal latch cannot be inspected or updated.
269    fn latch_connection_fate_intent_incomplete(
270        &self,
271        open_sequence: u64,
272        conversation_id: ConversationId,
273    ) -> Result<ParticipantServiceFatal, ParticipantSemanticError> {
274        Ok(ParticipantServiceFatal::ConnectionFateIntentIncomplete {
275            open_sequence,
276            conversation_id,
277        })
278    }
279
280    /// Applies every matching participant binding named by one durable Open.
281    ///
282    /// The incarnation-stream lock is not held while this method runs. Each
283    /// implementation serializes conversations independently and must return
284    /// only after every source and immediately executable specific fate flushes.
285    ///
286    /// # Errors
287    ///
288    /// Returns a semantic failure without consuming the Open; startup or the
289    /// live fatal path retains it for exact replay.
290    fn handle_connection_fate(
291        &self,
292        work_item: ConnectionFateWorkItem,
293    ) -> Result<(), ParticipantSemanticError> {
294        drop(work_item);
295        Err(ParticipantSemanticError::Unavailable)
296    }
297
298    /// Applies connection fate while preserving every committed conversation's
299    /// post-flush dispatch effects on both success and failure exits.
300    fn handle_connection_fate_with_impact(
301        &self,
302        work_item: ConnectionFateWorkItem,
303    ) -> ParticipantConnectionFateOutcome {
304        ParticipantConnectionFateOutcome::unchanged(self.handle_connection_fate(work_item))
305    }
306
307    /// Repairs every remaining binding owned by a prior server incarnation.
308    ///
309    /// Startup calls this after all retained Opens complete and before publishing
310    /// the incarnation authority, scheduler, listener, or new admission.
311    ///
312    /// # Errors
313    ///
314    /// Returns a semantic failure while startup still owns all publication seams.
315    fn repair_unclean_server_restart(
316        &self,
317        current_server_incarnation: u64,
318    ) -> Result<(), ParticipantSemanticError> {
319        let _ = current_server_incarnation;
320        Ok(())
321    }
322
323    /// Reports whether any listed conversation currently contains a Bound slot
324    /// owned by this exact connection. Terminal decode funnels use this query to
325    /// distinguish bound-only `ProtocolError` from pre-auth/detached/internal paths.
326    ///
327    /// # Errors
328    ///
329    /// Returns a semantic failure when exact bound authority cannot be inspected.
330    fn connection_has_bound_participant(
331        &self,
332        connection_incarnation: ConnectionIncarnation,
333        conversations: &[ConversationId],
334    ) -> Result<bool, ParticipantSemanticError> {
335        let _ = connection_incarnation;
336        let _ = conversations;
337        Ok(false)
338    }
339
340    fn publication_conversation_limit(&self) -> u64 {
341        0
342    }
343
344    /// Resolves all live current bindings with pending durable obligations for
345    /// one conversation. Production overrides this; semantic-only fixtures have
346    /// no publication source.
347    ///
348    /// # Errors
349    ///
350    /// Returns a semantic fault when durable readiness cannot be resolved.
351    fn ready_connection_incarnations(
352        &self,
353        _conversation_id: ConversationId,
354    ) -> Result<Vec<ConnectionIncarnation>, ParticipantSemanticError> {
355        Ok(Vec::new())
356    }
357
358    /// Selects the least durable recipient obligation for this incarnation,
359    /// restarting from durable ack when `offered` names an older binding.
360    ///
361    /// # Errors
362    ///
363    /// Returns a semantic fault when the durable obligation owner is unavailable.
364    fn next_publication(
365        &self,
366        _connection_incarnation: ConnectionIncarnation,
367        _conversation_id: ConversationId,
368        _offered: Option<ParticipantOfferedProgress>,
369    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
370        Ok(None)
371    }
372
373    /// Checks that a held head still belongs to the exact current binding before
374    /// it is offered after writable readiness.
375    ///
376    /// # Errors
377    ///
378    /// Returns a semantic fault when current binding authority cannot be read.
379    fn publication_binding_is_current(
380        &self,
381        _conversation_id: ConversationId,
382        _participant_id: ParticipantId,
383        _binding_epoch: BindingEpoch,
384    ) -> Result<bool, ParticipantSemanticError> {
385        Ok(false)
386    }
387
388    /// Re-selects a held publication against current binding, cursor, debt, and
389    /// outbox authority before its first offer. Semantic-only handlers retain
390    /// the binding-only default; production overrides this with the full locked
391    /// dispatch decision.
392    ///
393    /// # Errors
394    ///
395    /// Returns a semantic fault when current publication authority cannot be read.
396    fn publication_is_current(
397        &self,
398        publication: &ParticipantPublication,
399        offered: Option<ParticipantOfferedProgress>,
400    ) -> Result<bool, ParticipantSemanticError> {
401        if offered.is_some_and(|progress| progress.binding_epoch != publication.binding_epoch) {
402            return Ok(false);
403        }
404        self.publication_binding_is_current(
405            publication.conversation_id(),
406            publication.participant_id,
407            publication.binding_epoch,
408        )
409    }
410
411    /// Records exact successful marker enqueue testimony. Non-marker offers are
412    /// ignored by production after validating their current binding.
413    ///
414    /// # Errors
415    ///
416    /// Returns a semantic fault when exact offer testimony cannot be recorded.
417    fn record_publication_offer(
418        &self,
419        _publication: &ParticipantPublication,
420    ) -> Result<(), ParticipantSemanticError> {
421        Ok(())
422    }
423
424    /// Applies observer recovery with the weak exact-live-connection target
425    /// captured by the installed service. Semantic-only handlers delegate to
426    /// their ordinary request path and do not own observer publication.
427    ///
428    /// # Errors
429    ///
430    /// Returns [`ParticipantSemanticError`] under the same contract as
431    /// [`Self::handle`].
432    fn handle_observer_recovery(
433        &self,
434        context: ParticipantConnectionContext,
435        conversations: &mut ParticipantConnectionConversations,
436        request: ObserverRecoveryHandshake,
437        target: Option<ObserverPublicationTarget>,
438    ) -> Result<ServerValue, ParticipantSemanticError> {
439        drop(target);
440        self.handle(
441            context,
442            conversations,
443            ClientRequest::ObserverRecovery(request),
444        )
445    }
446
447    /// Applies one request and preserves post-commit effects on every exit.
448    ///
449    /// Semantic-only fixtures default to an empty accumulator. Production
450    /// overrides this boundary and returns operation-owned effects.
451    fn handle_with_impact(
452        &self,
453        context: ParticipantConnectionContext,
454        conversations: &mut ParticipantConnectionConversations,
455        request: ClientRequest,
456    ) -> ParticipantSemanticOutcome<ServerValue> {
457        ParticipantSemanticOutcome::unchanged(self.handle(context, conversations, request))
458    }
459
460    /// Applies one decoded participant request to protocol-owned authority.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`ParticipantSemanticError`] when durable or protocol authority
465    /// cannot produce a truthful terminal value. The connection fails rather
466    /// than fabricating a response.
467    fn handle(
468        &self,
469        context: ParticipantConnectionContext,
470        conversations: &mut ParticipantConnectionConversations,
471        request: ClientRequest,
472    ) -> Result<ServerValue, ParticipantSemanticError>;
473}
474
475/// Server-sealed participant activation token installed on a connection
476/// supervisor.
477///
478/// The semantic handler and its durable store form one value so participant
479/// capability activation cannot observe one without the other. The supervisor
480/// uses the store to durably allocate connection incarnations before spawning a
481/// connection process, and the process uses the handler only after that exact
482/// incarnation has been carried into its state. The token atomically carries the
483/// pair declared by server composition; it does not independently prove storage
484/// namespace identity.
485///
486/// Construction and access are server-private. Until a complete production
487/// lifecycle handler exists, external [`ConnectionServices`](crate::server::connection::ConnectionServices)
488/// implementations cannot manufacture an activation token or advertise the
489/// participant capability.
490#[derive(Clone, Debug)]
491pub struct InstalledParticipantService {
492    handler: Arc<dyn ParticipantSemanticHandler>,
493    durable_store: Arc<dyn DurableStore>,
494    frame_limit: ValidatedFrameLimit,
495    publication_registry: Arc<ParticipantPublicationRegistry>,
496}
497
498impl InstalledParticipantService {
499    /// Pairs a semantic handler, its declared durable store, and the raw
500    /// configured participant wire-frame limit.
501    ///
502    /// Production construction happens exactly once, in the server's
503    /// connection-services layer, from the deployment's `[participant]`
504    /// configuration; tests construct it directly with fixture handlers.
505    ///
506    /// # Errors
507    ///
508    /// Returns the shared codec error when the configured limit is smaller than
509    /// the protocol's minimum complete frame.
510    pub(crate) fn new(
511        handler: Arc<dyn ParticipantSemanticHandler>,
512        durable_store: Arc<dyn DurableStore>,
513        configured_wf: u64,
514    ) -> Result<Self, CodecError> {
515        Ok(Self {
516            handler,
517            durable_store,
518            frame_limit: normalize_configured_frame_limit(configured_wf)?,
519            publication_registry: Arc::new(ParticipantPublicationRegistry::default()),
520        })
521    }
522
523    /// Clones the durable store used by the installed participant service.
524    #[must_use]
525    pub(crate) fn durable_store(&self) -> Arc<dyn DurableStore> {
526        Arc::clone(&self.durable_store)
527    }
528
529    /// Returns the normalized configured complete-frame limit advertised by
530    /// this installed participant service.
531    #[must_use]
532    pub(crate) const fn frame_limit(&self) -> ValidatedFrameLimit {
533        self.frame_limit
534    }
535
536    /// Returns the signed semantic-conversation allowance shared by publication
537    /// readiness and connection-held encoded heads.
538    #[must_use]
539    pub(crate) fn publication_conversation_limit(&self) -> u64 {
540        self.handler.publication_conversation_limit()
541    }
542
543    /// Creates the strongly connection-owned ready inbox at process spawn.
544    #[must_use]
545    pub(crate) fn new_publication_inbox(&self) -> ParticipantPublicationInbox {
546        ParticipantPublicationInbox::new(self.handler.publication_conversation_limit())
547    }
548
549    /// Returns the shared weak publication registry.
550    #[must_use]
551    pub(crate) fn publication_registry(&self) -> &ParticipantPublicationRegistry {
552        &self.publication_registry
553    }
554
555    /// Selects one exact durable publication through the installed production
556    /// source.
557    pub(crate) fn next_publication(
558        &self,
559        connection_incarnation: ConnectionIncarnation,
560        conversation_id: ConversationId,
561        offered: Option<ParticipantOfferedProgress>,
562    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
563        self.handler
564            .next_publication(connection_incarnation, conversation_id, offered)
565    }
566
567    pub(crate) fn publication_is_current(
568        &self,
569        publication: &ParticipantPublication,
570        offered: Option<ParticipantOfferedProgress>,
571    ) -> Result<bool, ParticipantSemanticError> {
572        self.handler.publication_is_current(publication, offered)
573    }
574
575    pub(crate) fn record_publication_offer(
576        &self,
577        publication: &ParticipantPublication,
578    ) -> Result<(), ParticipantSemanticError> {
579        self.handler.record_publication_offer(publication)
580    }
581
582    fn notify_impact(&self, impact: &DispatchImpact) -> Result<(), ParticipantSemanticError> {
583        let Some(conversation_id) = impact.conversation_id() else {
584            return Ok(());
585        };
586        for target in impact.target_union() {
587            self.publication_registry
588                .notify(
589                    target.binding_epoch().connection_incarnation,
590                    conversation_id,
591                )
592                .map_err(|error| ParticipantSemanticError::Internal {
593                    message: format!("participant publication wake failed: {error}"),
594                })?;
595        }
596        Ok(())
597    }
598}
599
600impl ParticipantSemanticHandler for InstalledParticipantService {
601    fn service_fatal(&self) -> Result<Option<ParticipantServiceFatal>, ParticipantSemanticError> {
602        self.handler.service_fatal()
603    }
604
605    fn latch_connection_fate_intent_incomplete(
606        &self,
607        open_sequence: u64,
608        conversation_id: ConversationId,
609    ) -> Result<ParticipantServiceFatal, ParticipantSemanticError> {
610        self.handler
611            .latch_connection_fate_intent_incomplete(open_sequence, conversation_id)
612    }
613
614    fn publication_conversation_limit(&self) -> u64 {
615        self.handler.publication_conversation_limit()
616    }
617
618    fn handle_connection_fate(
619        &self,
620        work_item: ConnectionFateWorkItem,
621    ) -> Result<(), ParticipantSemanticError> {
622        let outcome = self.handler.handle_connection_fate_with_impact(work_item);
623        let (result, impacts) = outcome.into_parts();
624        for impact in &impacts {
625            self.notify_impact(impact)?;
626        }
627        result
628    }
629
630    fn handle_connection_fate_with_impact(
631        &self,
632        work_item: ConnectionFateWorkItem,
633    ) -> ParticipantConnectionFateOutcome {
634        let result = self.handle_connection_fate(work_item);
635        ParticipantConnectionFateOutcome::unchanged(result)
636    }
637
638    fn repair_unclean_server_restart(
639        &self,
640        current_server_incarnation: u64,
641    ) -> Result<(), ParticipantSemanticError> {
642        self.handler
643            .repair_unclean_server_restart(current_server_incarnation)
644    }
645
646    fn connection_has_bound_participant(
647        &self,
648        connection_incarnation: ConnectionIncarnation,
649        conversations: &[ConversationId],
650    ) -> Result<bool, ParticipantSemanticError> {
651        self.handler
652            .connection_has_bound_participant(connection_incarnation, conversations)
653    }
654
655    fn handle(
656        &self,
657        context: ParticipantConnectionContext,
658        conversations: &mut ParticipantConnectionConversations,
659        request: ClientRequest,
660    ) -> Result<ServerValue, ParticipantSemanticError> {
661        if let ClientRequest::ObserverRecovery(request) = request {
662            let target = self
663                .publication_registry
664                .observer_target(context.connection_incarnation())
665                .map_err(|error| ParticipantSemanticError::Internal {
666                    message: format!("observer publication target failed: {error}"),
667                })?;
668            return self
669                .handler
670                .handle_observer_recovery(context, conversations, request, target);
671        }
672        let outcome = self
673            .handler
674            .handle_with_impact(context, conversations, request);
675        let (result, impact) = outcome.into_parts();
676        self.notify_impact(&impact)?;
677        result
678    }
679}
680
681/// Result of dispatching one generic frame through participant transport.
682#[derive(Debug)]
683pub enum ParticipantDispatch {
684    /// The generic frame belongs to another protocol.
685    NotParticipant,
686    /// Exact encoded response selected by the shared gate or semantic handler.
687    Respond(Frame),
688    /// Exact crate-owned pre-semantic rejection, followed by connection close.
689    RespondThenClose(Frame),
690    /// No truthful participant response exists; the connection must fail closed.
691    Fatal(ParticipantDispatchError),
692}
693
694/// Failure after a generic frame has entered participant dispatch.
695#[derive(Debug, thiserror::Error)]
696pub enum ParticipantDispatchError {
697    /// The preserved generic frame could not represent a canonical participant frame.
698    #[error("invalid generic participant frame")]
699    InvalidGenericFrame,
700    /// The semantic handler could not produce a protocol value.
701    #[error(transparent)]
702    Semantic(#[from] ParticipantSemanticError),
703    /// The crate-produced value could not be encoded into the generic transport.
704    #[error("failed to encode participant response: {0:?}")]
705    Encode(CodecError),
706}
707
708/// Gates, decodes, semantically applies, and encodes one participant frame.
709///
710/// Transport rejection values originate in `liminal-protocol`; semantic values
711/// originate only in `handler`. No lifecycle outcome is constructed here.
712#[must_use]
713pub fn dispatch_generic_frame(
714    frame: &Frame,
715    authenticated: bool,
716    session: ParticipantSession,
717    context: ParticipantConnectionContext,
718    conversations: &mut ParticipantConnectionConversations,
719    handler: &dyn ParticipantSemanticHandler,
720) -> ParticipantDispatch {
721    let (value, close_after_response) = match gate_generic_frame(frame, authenticated, session) {
722        ParticipantIngress::NotParticipant => return ParticipantDispatch::NotParticipant,
723        ParticipantIngress::Rejected(rejection) => {
724            (ServerValue::ParticipantTransportRejected(rejection), true)
725        }
726        ParticipantIngress::InvalidGenericFrame => {
727            return ParticipantDispatch::Fatal(ParticipantDispatchError::InvalidGenericFrame);
728        }
729        ParticipantIngress::Request(request) => {
730            match handler.handle(context, conversations, request) {
731                Ok(value) => (value, false),
732                Err(error) => {
733                    return ParticipantDispatch::Fatal(ParticipantDispatchError::Semantic(error));
734                }
735            }
736        }
737    };
738    match encode_server_value(value) {
739        Ok(frame) if close_after_response => ParticipantDispatch::RespondThenClose(frame),
740        Ok(frame) => ParticipantDispatch::Respond(frame),
741        Err(error) => ParticipantDispatch::Fatal(ParticipantDispatchError::Encode(error)),
742    }
743}