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