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::{BindingTerminalAdmitError, 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/// Why F8B boot recovery could not empty a restored conversation's
142/// immutable-candidate lane (`docs/design/F8B-INTENT-DEADLOCK.md` §6.2
143/// R-BOOT-VERDICT).
144///
145/// The discrimination is BY TYPE, for the same reason
146/// [`ParticipantSemanticError::BindingTerminalAdmissionRefused`] carries
147/// [`BindingTerminalAdmitError`]: a consumer deciding what a refused boot
148/// means must not read it out of a formatted message.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum BootDrainRefusal {
151    /// The lane head is a pending binding terminal under an armed
152    /// fenced-attach recovery block. The terminal drain refuses outright while
153    /// a recovery block is armed, and the only consumer of a recovery block is
154    /// a live fenced attach — which boot cannot perform. Such a store is not
155    /// repairable by the boot drain, and this verdict is the honest answer
156    /// rather than a repair.
157    RecoveryArmed,
158    /// Any other drain refusal: the head was reachable, the drain was
159    /// attempted, and the protocol refused the transition.
160    Shape,
161}
162
163/// Non-wire semantic service failure.
164///
165/// A failure is terminal to the connection attempt. It is deliberately not
166/// convertible to [`ServerValue`], preventing the server from inventing a
167/// lifecycle response when the protocol-owned transition did not produce one.
168#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
169pub enum ParticipantSemanticError {
170    /// The complete semantic service is not installed.
171    #[error("participant semantic service is unavailable")]
172    Unavailable,
173    /// Durable state or a protocol invariant prevented semantic completion.
174    #[error("participant semantic service failed: {message}")]
175    Internal {
176        /// Diagnostic text for server logs; never placed on the participant wire.
177        message: String,
178    },
179    /// A process-wide participant fatal has already latched.
180    #[error(transparent)]
181    ServiceFatal(ParticipantServiceFatal),
182    /// A keyed binding-terminal candidate was refused, carrying the protocol's
183    /// own reason rather than a formatted description of it.
184    ///
185    /// [`BindingTerminalAdmitError::Precedence`] is lane occupancy: the
186    /// conversation's immutable-candidate lane already holds a terminal
187    /// awaiting its drain. That is a designed structural boundary, and a
188    /// caller deciding whether to park or to treat the refusal as corruption
189    /// must be able to tell it from the five genuine authority defects by
190    /// type.
191    #[error("participant binding-terminal admission refused: {error:?}")]
192    BindingTerminalAdmissionRefused {
193        /// Exact protocol refusal reason.
194        error: BindingTerminalAdmitError,
195    },
196    /// F8B R-BOOT-VERDICT: boot recovery could not empty a restored
197    /// conversation's immutable-candidate lane, so the boot refuses HERE,
198    /// naming the conversation and the shape, instead of starting and dying
199    /// several collapses downstream on a retained `Open` it can never replay.
200    #[error(
201        "participant boot drain refused conversation {conversation_id} on lane head {candidate} \
202         ({refusal:?}): {reason} — docs/design/F8B-INTENT-DEADLOCK.md §6.2 R-BOOT-VERDICT"
203    )]
204    BootDrainRefused {
205        /// Conversation whose restored lane refused its drain.
206        conversation_id: ConversationId,
207        /// Typed reason, so a consumer never discriminates on a substring.
208        refusal: BootDrainRefusal,
209        /// Exact lane head that refused, rendered for the operator.
210        candidate: String,
211        /// The drain's own refusal text.
212        reason: String,
213    },
214    /// F8B R-SEAL: the conversation is Closed — a Died-flavor drain erased its
215    /// final enrollment token, so its log holds records, terminals and drain
216    /// rows but no live identity can ever be reached through it again.
217    ///
218    /// Enrollment answers with this NAMED refusal rather than falling through
219    /// to a fresh identity, which would silently re-open a conversation whose
220    /// history has already ended. On the wire it rides the existing
221    /// semantic-error framing; a protocol-native response value with its own
222    /// discriminant is deferred to a protocol-version leg (§9.8).
223    #[error(
224        "participant enrollment refused: conversation {conversation_id} is sealed — its final \
225         enrollment token was erased by a binding-terminal drain — \
226         docs/design/F8B-INTENT-DEADLOCK.md §6.6 R-SEAL"
227    )]
228    ConversationSealed {
229        /// Conversation whose closure refused the request.
230        conversation_id: ConversationId,
231    },
232    /// CONTAINMENT: this one conversation's durable state cannot be loaded, so
233    /// this one conversation is refused. The node starts, every other
234    /// conversation is served, and the refusal NAMES its subject.
235    ///
236    /// Attribution is not decoration here, it is half the property. A node
237    /// that contains an unloadable conversation without naming it boots clean
238    /// and silently serves nothing on that conversation forever, which is
239    /// worse than the crash it replaced, because the crash was the only thing
240    /// telling anyone. The underlying failure travels as `reason` rather than
241    /// as a wrapped error because it is already a rendered diagnostic by the
242    /// time replay refuses it.
243    #[error(
244        "participant conversation {conversation_id} is unloadable and is refused on its own: \
245         {reason}"
246    )]
247    ConversationUnloadable {
248        /// Conversation whose durable state could not be loaded.
249        conversation_id: ConversationId,
250        /// The load failure's own text, as the operator needs to see it.
251        reason: String,
252    },
253}
254
255/// One semantic result paired with every dispatch effect durably installed by
256/// the request before it returned.
257///
258/// The envelope deliberately owns the `Result`: a marker-drain prefix can
259/// commit before a later retry fails, and that failure must not erase the
260/// prefix's post-commit tell.
261#[derive(Debug)]
262pub struct ParticipantSemanticOutcome<T> {
263    result: Result<T, ParticipantSemanticError>,
264    impact: DispatchImpact,
265}
266
267impl<T> ParticipantSemanticOutcome<T> {
268    /// Wraps a fixture or operation which installed no dispatch effect.
269    #[must_use]
270    pub const fn unchanged(result: Result<T, ParticipantSemanticError>) -> Self {
271        Self {
272            result,
273            impact: DispatchImpact::Unchanged,
274        }
275    }
276
277    /// Carries an operation result and its complete request accumulator.
278    #[must_use]
279    pub const fn new(result: Result<T, ParticipantSemanticError>, impact: DispatchImpact) -> Self {
280        Self { result, impact }
281    }
282
283    pub(crate) fn into_parts(self) -> (Result<T, ParticipantSemanticError>, DispatchImpact) {
284        (self.result, self.impact)
285    }
286
287    /// Returns the semantic result when an internal caller has no notification
288    /// boundary. Production request dispatch uses the complete envelope; this
289    /// projection exists for the trait's legacy direct-call entry point.
290    pub(crate) fn into_result(self) -> Result<T, ParticipantSemanticError> {
291        self.result
292    }
293}
294
295/// One connection-fate result paired with every conversation impact committed
296/// before the fate operation returned.
297#[derive(Debug)]
298pub struct ParticipantConnectionFateOutcome {
299    result: Result<(), ParticipantSemanticError>,
300    impacts: Vec<DispatchImpact>,
301}
302
303impl ParticipantConnectionFateOutcome {
304    /// Wraps a fixture fate handler which committed no dispatch impact.
305    #[must_use]
306    pub const fn unchanged(result: Result<(), ParticipantSemanticError>) -> Self {
307        Self {
308            result,
309            impacts: Vec::new(),
310        }
311    }
312
313    /// Carries a fate result and every committed per-conversation impact.
314    #[must_use]
315    pub const fn new(
316        result: Result<(), ParticipantSemanticError>,
317        impacts: Vec<DispatchImpact>,
318    ) -> Self {
319        Self { result, impacts }
320    }
321
322    pub(crate) fn into_parts(self) -> (Result<(), ParticipantSemanticError>, Vec<DispatchImpact>) {
323        (self.result, self.impacts)
324    }
325
326    pub(crate) fn into_result(self) -> Result<(), ParticipantSemanticError> {
327        self.result
328    }
329}
330
331/// Server-owned adapter from a decoded request to a protocol-owned value.
332pub trait ParticipantSemanticHandler: core::fmt::Debug + Send + Sync {
333    /// Applies one already authenticated and capability-gated request.
334    ///
335    /// `conversations` is the receiving connection's semantic-conversation
336    /// dispatch map: the handler reads it for the crate's stage-6
337    /// connection-conversation capacity facts and installs a slot exactly
338    /// when an operation's capacity commit reports `newly_tracked`.
339    ///
340    /// # Errors
341    ///
342    /// Returns [`ParticipantSemanticError`] when no protocol value can be
343    /// produced. The caller closes rather than fabricating a response.
344    ///
345    /// Production handlers override this with the signed
346    /// `max_semantic_conversations_per_connection`; semantic-only fixtures own
347    /// no publication conversations.
348    ///
349    /// Returns the latched fatal, when participant service must remain stopped.
350    fn service_fatal(&self) -> Result<Option<ParticipantServiceFatal>, ParticipantSemanticError> {
351        Ok(None)
352    }
353
354    /// Atomically latches the post-Open fatal selected by Decision B.
355    ///
356    /// Implementations must preserve the first fatal and return it on every later call.
357    /// The default exists only for semantic fixtures which own no durable intents.
358    ///
359    /// # Errors
360    ///
361    /// Returns a semantic service error when the fatal latch cannot be inspected or updated.
362    fn latch_connection_fate_intent_incomplete(
363        &self,
364        open_sequence: u64,
365        conversation_id: ConversationId,
366    ) -> Result<ParticipantServiceFatal, ParticipantSemanticError> {
367        Ok(ParticipantServiceFatal::ConnectionFateIntentIncomplete {
368            open_sequence,
369            conversation_id,
370        })
371    }
372
373    /// Applies every matching participant binding named by one durable Open.
374    ///
375    /// The incarnation-stream lock is not held while this method runs. Each
376    /// implementation serializes conversations independently and must return
377    /// only after every source and immediately executable specific fate flushes.
378    ///
379    /// # Errors
380    ///
381    /// Returns a semantic failure without consuming the Open; startup or the
382    /// live fatal path retains it for exact replay.
383    fn handle_connection_fate(
384        &self,
385        work_item: ConnectionFateWorkItem,
386    ) -> Result<(), ParticipantSemanticError> {
387        drop(work_item);
388        Err(ParticipantSemanticError::Unavailable)
389    }
390
391    /// Applies connection fate while preserving every committed conversation's
392    /// post-flush dispatch effects on both success and failure exits.
393    fn handle_connection_fate_with_impact(
394        &self,
395        work_item: ConnectionFateWorkItem,
396    ) -> ParticipantConnectionFateOutcome {
397        ParticipantConnectionFateOutcome::unchanged(self.handle_connection_fate(work_item))
398    }
399
400    /// Repairs every remaining binding owned by a prior server incarnation.
401    ///
402    /// Startup calls this after all retained Opens complete and before publishing
403    /// the incarnation authority, scheduler, listener, or new admission.
404    ///
405    /// # Errors
406    ///
407    /// Returns a semantic failure while startup still owns all publication seams.
408    fn repair_unclean_server_restart(
409        &self,
410        current_server_incarnation: u64,
411    ) -> Result<(), ParticipantSemanticError> {
412        let _ = current_server_incarnation;
413        Ok(())
414    }
415
416    /// Reports whether any listed conversation currently contains a Bound slot
417    /// owned by this exact connection. Terminal decode funnels use this query to
418    /// distinguish bound-only `ProtocolError` from pre-auth/detached/internal paths.
419    ///
420    /// # Errors
421    ///
422    /// Returns a semantic failure when exact bound authority cannot be inspected.
423    fn connection_has_bound_participant(
424        &self,
425        connection_incarnation: ConnectionIncarnation,
426        conversations: &[ConversationId],
427    ) -> Result<bool, ParticipantSemanticError> {
428        let _ = connection_incarnation;
429        let _ = conversations;
430        Ok(false)
431    }
432
433    fn publication_conversation_limit(&self) -> u64 {
434        0
435    }
436
437    /// Resolves all live current bindings with pending durable obligations for
438    /// one conversation. Production overrides this; semantic-only fixtures have
439    /// no publication source.
440    ///
441    /// # Errors
442    ///
443    /// Returns a semantic fault when durable readiness cannot be resolved.
444    fn ready_connection_incarnations(
445        &self,
446        _conversation_id: ConversationId,
447    ) -> Result<Vec<ConnectionIncarnation>, ParticipantSemanticError> {
448        Ok(Vec::new())
449    }
450
451    /// Selects the least durable recipient obligation for this incarnation,
452    /// restarting from durable ack when `offered` names an older binding.
453    ///
454    /// # Errors
455    ///
456    /// Returns a semantic fault when the durable obligation owner is unavailable.
457    fn next_publication(
458        &self,
459        _connection_incarnation: ConnectionIncarnation,
460        _conversation_id: ConversationId,
461        _offered: Option<ParticipantOfferedProgress>,
462    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
463        Ok(None)
464    }
465
466    /// Checks that a held head still belongs to the exact current binding before
467    /// it is offered after writable readiness.
468    ///
469    /// # Errors
470    ///
471    /// Returns a semantic fault when current binding authority cannot be read.
472    fn publication_binding_is_current(
473        &self,
474        _conversation_id: ConversationId,
475        _participant_id: ParticipantId,
476        _binding_epoch: BindingEpoch,
477    ) -> Result<bool, ParticipantSemanticError> {
478        Ok(false)
479    }
480
481    /// Re-selects a held publication against current binding, cursor, debt, and
482    /// outbox authority before its first offer. Semantic-only handlers retain
483    /// the binding-only default; production overrides this with the full locked
484    /// dispatch decision.
485    ///
486    /// # Errors
487    ///
488    /// Returns a semantic fault when current publication authority cannot be read.
489    fn publication_is_current(
490        &self,
491        publication: &ParticipantPublication,
492        offered: Option<ParticipantOfferedProgress>,
493    ) -> Result<bool, ParticipantSemanticError> {
494        if offered.is_some_and(|progress| progress.binding_epoch != publication.binding_epoch) {
495            return Ok(false);
496        }
497        self.publication_binding_is_current(
498            publication.conversation_id(),
499            publication.participant_id,
500            publication.binding_epoch,
501        )
502    }
503
504    /// Records exact successful marker enqueue testimony. Non-marker offers are
505    /// ignored by production after validating their current binding.
506    ///
507    /// # Errors
508    ///
509    /// Returns a semantic fault when exact offer testimony cannot be recorded.
510    fn record_publication_offer(
511        &self,
512        _publication: &ParticipantPublication,
513    ) -> Result<(), ParticipantSemanticError> {
514        Ok(())
515    }
516
517    /// Applies observer recovery with the weak exact-live-connection target
518    /// captured by the installed service. Semantic-only handlers delegate to
519    /// their ordinary request path and do not own observer publication.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`ParticipantSemanticError`] under the same contract as
524    /// [`Self::handle`].
525    fn handle_observer_recovery(
526        &self,
527        context: ParticipantConnectionContext,
528        conversations: &mut ParticipantConnectionConversations,
529        request: ObserverRecoveryHandshake,
530        target: Option<ObserverPublicationTarget>,
531    ) -> Result<ServerValue, ParticipantSemanticError> {
532        drop(target);
533        self.handle(
534            context,
535            conversations,
536            ClientRequest::ObserverRecovery(request),
537        )
538    }
539
540    /// Applies one request and preserves post-commit effects on every exit.
541    ///
542    /// Semantic-only fixtures default to an empty accumulator. Production
543    /// overrides this boundary and returns operation-owned effects.
544    fn handle_with_impact(
545        &self,
546        context: ParticipantConnectionContext,
547        conversations: &mut ParticipantConnectionConversations,
548        request: ClientRequest,
549    ) -> ParticipantSemanticOutcome<ServerValue> {
550        ParticipantSemanticOutcome::unchanged(self.handle(context, conversations, request))
551    }
552
553    /// Applies one decoded participant request to protocol-owned authority.
554    ///
555    /// # Errors
556    ///
557    /// Returns [`ParticipantSemanticError`] when durable or protocol authority
558    /// cannot produce a truthful terminal value. The connection fails rather
559    /// than fabricating a response.
560    fn handle(
561        &self,
562        context: ParticipantConnectionContext,
563        conversations: &mut ParticipantConnectionConversations,
564        request: ClientRequest,
565    ) -> Result<ServerValue, ParticipantSemanticError>;
566}
567
568/// Server-sealed participant activation token installed on a connection
569/// supervisor.
570///
571/// The semantic handler and its durable store form one value so participant
572/// capability activation cannot observe one without the other. The supervisor
573/// uses the store to durably allocate connection incarnations before spawning a
574/// connection process, and the process uses the handler only after that exact
575/// incarnation has been carried into its state. The token atomically carries the
576/// pair declared by server composition; it does not independently prove storage
577/// namespace identity.
578///
579/// Construction and access are server-private. Until a complete production
580/// lifecycle handler exists, external [`ConnectionServices`](crate::server::connection::ConnectionServices)
581/// implementations cannot manufacture an activation token or advertise the
582/// participant capability.
583#[derive(Clone, Debug)]
584pub struct InstalledParticipantService {
585    handler: Arc<dyn ParticipantSemanticHandler>,
586    durable_store: Arc<dyn DurableStore>,
587    frame_limit: ValidatedFrameLimit,
588    publication_registry: Arc<ParticipantPublicationRegistry>,
589}
590
591impl InstalledParticipantService {
592    /// Pairs a semantic handler, its declared durable store, and the raw
593    /// configured participant wire-frame limit.
594    ///
595    /// Production construction happens exactly once, in the server's
596    /// connection-services layer, from the deployment's `[participant]`
597    /// configuration; tests construct it directly with fixture handlers.
598    ///
599    /// # Errors
600    ///
601    /// Returns the shared codec error when the configured limit is smaller than
602    /// the protocol's minimum complete frame.
603    pub(crate) fn new(
604        handler: Arc<dyn ParticipantSemanticHandler>,
605        durable_store: Arc<dyn DurableStore>,
606        configured_wf: u64,
607    ) -> Result<Self, CodecError> {
608        Ok(Self {
609            handler,
610            durable_store,
611            frame_limit: normalize_configured_frame_limit(configured_wf)?,
612            publication_registry: Arc::new(ParticipantPublicationRegistry::default()),
613        })
614    }
615
616    /// Clones the durable store used by the installed participant service.
617    #[must_use]
618    pub(crate) fn durable_store(&self) -> Arc<dyn DurableStore> {
619        Arc::clone(&self.durable_store)
620    }
621
622    /// Returns the normalized configured complete-frame limit advertised by
623    /// this installed participant service.
624    #[must_use]
625    pub(crate) const fn frame_limit(&self) -> ValidatedFrameLimit {
626        self.frame_limit
627    }
628
629    /// Returns the signed semantic-conversation allowance shared by publication
630    /// readiness and connection-held encoded heads.
631    #[must_use]
632    pub(crate) fn publication_conversation_limit(&self) -> u64 {
633        self.handler.publication_conversation_limit()
634    }
635
636    /// Creates the strongly connection-owned ready inbox at process spawn.
637    #[must_use]
638    pub(crate) fn new_publication_inbox(&self) -> ParticipantPublicationInbox {
639        ParticipantPublicationInbox::new(self.handler.publication_conversation_limit())
640    }
641
642    /// Returns the shared weak publication registry.
643    #[must_use]
644    pub(crate) fn publication_registry(&self) -> &ParticipantPublicationRegistry {
645        &self.publication_registry
646    }
647
648    /// Selects one exact durable publication through the installed production
649    /// source.
650    pub(crate) fn next_publication(
651        &self,
652        connection_incarnation: ConnectionIncarnation,
653        conversation_id: ConversationId,
654        offered: Option<ParticipantOfferedProgress>,
655    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
656        self.handler
657            .next_publication(connection_incarnation, conversation_id, offered)
658    }
659
660    pub(crate) fn publication_is_current(
661        &self,
662        publication: &ParticipantPublication,
663        offered: Option<ParticipantOfferedProgress>,
664    ) -> Result<bool, ParticipantSemanticError> {
665        self.handler.publication_is_current(publication, offered)
666    }
667
668    pub(crate) fn record_publication_offer(
669        &self,
670        publication: &ParticipantPublication,
671    ) -> Result<(), ParticipantSemanticError> {
672        self.handler.record_publication_offer(publication)
673    }
674
675    fn notify_impact(&self, impact: &DispatchImpact) -> Result<(), ParticipantSemanticError> {
676        let Some(conversation_id) = impact.conversation_id() else {
677            return Ok(());
678        };
679        for target in impact.target_union() {
680            self.publication_registry
681                .notify(
682                    target.binding_epoch().connection_incarnation,
683                    conversation_id,
684                )
685                .map_err(|error| ParticipantSemanticError::Internal {
686                    message: format!("participant publication wake failed: {error}"),
687                })?;
688        }
689        Ok(())
690    }
691}
692
693impl ParticipantSemanticHandler for InstalledParticipantService {
694    fn service_fatal(&self) -> Result<Option<ParticipantServiceFatal>, ParticipantSemanticError> {
695        self.handler.service_fatal()
696    }
697
698    fn latch_connection_fate_intent_incomplete(
699        &self,
700        open_sequence: u64,
701        conversation_id: ConversationId,
702    ) -> Result<ParticipantServiceFatal, ParticipantSemanticError> {
703        self.handler
704            .latch_connection_fate_intent_incomplete(open_sequence, conversation_id)
705    }
706
707    fn publication_conversation_limit(&self) -> u64 {
708        self.handler.publication_conversation_limit()
709    }
710
711    fn handle_connection_fate(
712        &self,
713        work_item: ConnectionFateWorkItem,
714    ) -> Result<(), ParticipantSemanticError> {
715        let outcome = self.handler.handle_connection_fate_with_impact(work_item);
716        let (result, impacts) = outcome.into_parts();
717        for impact in &impacts {
718            self.notify_impact(impact)?;
719        }
720        result
721    }
722
723    fn handle_connection_fate_with_impact(
724        &self,
725        work_item: ConnectionFateWorkItem,
726    ) -> ParticipantConnectionFateOutcome {
727        let result = self.handle_connection_fate(work_item);
728        ParticipantConnectionFateOutcome::unchanged(result)
729    }
730
731    fn repair_unclean_server_restart(
732        &self,
733        current_server_incarnation: u64,
734    ) -> Result<(), ParticipantSemanticError> {
735        self.handler
736            .repair_unclean_server_restart(current_server_incarnation)
737    }
738
739    fn connection_has_bound_participant(
740        &self,
741        connection_incarnation: ConnectionIncarnation,
742        conversations: &[ConversationId],
743    ) -> Result<bool, ParticipantSemanticError> {
744        self.handler
745            .connection_has_bound_participant(connection_incarnation, conversations)
746    }
747
748    fn handle(
749        &self,
750        context: ParticipantConnectionContext,
751        conversations: &mut ParticipantConnectionConversations,
752        request: ClientRequest,
753    ) -> Result<ServerValue, ParticipantSemanticError> {
754        if let ClientRequest::ObserverRecovery(request) = request {
755            let target = self
756                .publication_registry
757                .observer_target(context.connection_incarnation())
758                .map_err(|error| ParticipantSemanticError::Internal {
759                    message: format!("observer publication target failed: {error}"),
760                })?;
761            return self
762                .handler
763                .handle_observer_recovery(context, conversations, request, target);
764        }
765        let outcome = self
766            .handler
767            .handle_with_impact(context, conversations, request);
768        let (result, impact) = outcome.into_parts();
769        self.notify_impact(&impact)?;
770        result
771    }
772}
773
774/// Result of dispatching one generic frame through participant transport.
775#[derive(Debug)]
776pub enum ParticipantDispatch {
777    /// The generic frame belongs to another protocol.
778    NotParticipant,
779    /// Exact encoded response selected by the shared gate or semantic handler.
780    Respond(Frame),
781    /// Exact crate-owned pre-semantic rejection, followed by connection close.
782    RespondThenClose(Frame),
783    /// No truthful participant response exists; the connection must fail closed.
784    Fatal(ParticipantDispatchError),
785}
786
787/// Failure after a generic frame has entered participant dispatch.
788#[derive(Debug, thiserror::Error)]
789pub enum ParticipantDispatchError {
790    /// The preserved generic frame could not represent a canonical participant frame.
791    #[error("invalid generic participant frame")]
792    InvalidGenericFrame,
793    /// The semantic handler could not produce a protocol value.
794    #[error(transparent)]
795    Semantic(#[from] ParticipantSemanticError),
796    /// The crate-produced value could not be encoded into the generic transport.
797    #[error("failed to encode participant response: {0:?}")]
798    Encode(CodecError),
799}
800
801/// Gates, decodes, semantically applies, and encodes one participant frame.
802///
803/// Transport rejection values originate in `liminal-protocol`; semantic values
804/// originate only in `handler`. No lifecycle outcome is constructed here.
805#[must_use]
806pub fn dispatch_generic_frame(
807    frame: &Frame,
808    authenticated: bool,
809    session: ParticipantSession,
810    context: ParticipantConnectionContext,
811    conversations: &mut ParticipantConnectionConversations,
812    handler: &dyn ParticipantSemanticHandler,
813) -> ParticipantDispatch {
814    let (value, close_after_response) = match gate_generic_frame(frame, authenticated, session) {
815        ParticipantIngress::NotParticipant => return ParticipantDispatch::NotParticipant,
816        ParticipantIngress::Rejected(rejection) => {
817            (ServerValue::ParticipantTransportRejected(rejection), true)
818        }
819        ParticipantIngress::InvalidGenericFrame => {
820            return ParticipantDispatch::Fatal(ParticipantDispatchError::InvalidGenericFrame);
821        }
822        ParticipantIngress::Request(request) => {
823            match handler.handle(context, conversations, request) {
824                Ok(value) => (value, false),
825                Err(error) => {
826                    return ParticipantDispatch::Fatal(ParticipantDispatchError::Semantic(error));
827                }
828            }
829        }
830    };
831    match encode_server_value(value) {
832        Ok(frame) if close_after_response => ParticipantDispatch::RespondThenClose(frame),
833        Ok(frame) => ParticipantDispatch::Respond(frame),
834        Err(error) => ParticipantDispatch::Fatal(ParticipantDispatchError::Encode(error)),
835    }
836}