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::transport::{
19    ParticipantIngress, ParticipantSession, encode_server_value, gate_generic_frame,
20    normalize_configured_frame_limit,
21};
22use super::{
23    ObserverPublicationTarget, ParticipantOfferedProgress, ParticipantPublication,
24    ParticipantPublicationInbox, ParticipantPublicationRegistry,
25};
26
27/// Connection-local semantic-conversation dispatch map (contract R-D1: the
28/// connection's binding/interest/dispatch maps are bounded by the signed
29/// `max_semantic_conversations_per_connection`).
30///
31/// One value lives in each connection process's state for the connection's
32/// lifetime and is dropped with it. A conversation enters the map exactly
33/// when a semantic operation for it COMMITS on this connection (the crate's
34/// `ConnectionConversationCapacityCommit::newly_tracked` verdict) or when an
35/// observer-recovery batch arms its refusal-only recipient; refusals and
36/// replays leave the map untouched, exactly as the crate's stage-6 selector
37/// leaves its counter unchanged. Growth is therefore bounded by the signed
38/// limit the stage-6 selector enforces.
39#[derive(Debug, Default)]
40pub struct ParticipantConnectionConversations {
41    tracked: BTreeSet<ConversationId>,
42}
43
44impl ParticipantConnectionConversations {
45    /// Stage-6 tracking fact for one conversation on this connection.
46    #[must_use]
47    pub fn tracking(&self, conversation_id: ConversationId) -> ConnectionConversationTracking {
48        if self.tracked.contains(&conversation_id) {
49            ConnectionConversationTracking::AlreadyTracked
50        } else {
51            ConnectionConversationTracking::Untracked
52        }
53    }
54
55    /// Current connection-conversation occupancy.
56    #[must_use]
57    pub fn occupied(&self) -> u64 {
58        // `usize` fits `u64` on every supported target; if that ever stopped
59        // holding, saturating at MAX fails CLOSED (capacity reads as full)
60        // rather than silently under-counting occupancy.
61        u64::try_from(self.tracked.len()).unwrap_or(u64::MAX)
62    }
63
64    /// Installs one conversation slot after a capacity-committing operation.
65    pub fn track(&mut self, conversation_id: ConversationId) {
66        self.tracked.insert(conversation_id);
67    }
68
69    /// Sorted tracked conversations (the observer-recovery preflight's
70    /// current-occupancy input).
71    #[must_use]
72    pub fn tracked_conversations(&self) -> Vec<ConversationId> {
73        self.tracked.iter().copied().collect()
74    }
75}
76
77/// Connection-scoped authority facts supplied to participant semantics.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub struct ParticipantConnectionContext {
80    connection_incarnation: ConnectionIncarnation,
81}
82
83impl ParticipantConnectionContext {
84    /// Captures the durably allocated incarnation of the receiving connection.
85    #[must_use]
86    pub const fn new(connection_incarnation: ConnectionIncarnation) -> Self {
87        Self {
88            connection_incarnation,
89        }
90    }
91
92    /// Returns the durably allocated receiving-connection incarnation.
93    #[must_use]
94    pub const fn connection_incarnation(self) -> ConnectionIncarnation {
95        self.connection_incarnation
96    }
97}
98
99/// Non-wire semantic service failure.
100///
101/// A failure is terminal to the connection attempt. It is deliberately not
102/// convertible to [`ServerValue`], preventing the server from inventing a
103/// lifecycle response when the protocol-owned transition did not produce one.
104#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
105pub enum ParticipantSemanticError {
106    /// The complete semantic service is not installed.
107    #[error("participant semantic service is unavailable")]
108    Unavailable,
109    /// Durable state or a protocol invariant prevented semantic completion.
110    #[error("participant semantic service failed: {message}")]
111    Internal {
112        /// Diagnostic text for server logs; never placed on the participant wire.
113        message: String,
114    },
115}
116
117/// Server-owned adapter from a decoded request to a protocol-owned value.
118pub trait ParticipantSemanticHandler: core::fmt::Debug + Send + Sync {
119    /// Applies one already authenticated and capability-gated request.
120    ///
121    /// `conversations` is the receiving connection's semantic-conversation
122    /// dispatch map: the handler reads it for the crate's stage-6
123    /// connection-conversation capacity facts and installs a slot exactly
124    /// when an operation's capacity commit reports `newly_tracked`.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`ParticipantSemanticError`] when no protocol value can be
129    /// produced. The caller closes rather than fabricating a response.
130    ///
131    /// Production handlers override this with the signed
132    /// `max_semantic_conversations_per_connection`; semantic-only fixtures own
133    /// no publication conversations.
134    fn publication_conversation_limit(&self) -> u64 {
135        0
136    }
137
138    /// Resolves all live current bindings with pending durable obligations for
139    /// one conversation. Production overrides this; semantic-only fixtures have
140    /// no publication source.
141    ///
142    /// # Errors
143    ///
144    /// Returns a semantic fault when durable readiness cannot be resolved.
145    fn ready_connection_incarnations(
146        &self,
147        _conversation_id: ConversationId,
148    ) -> Result<Vec<ConnectionIncarnation>, ParticipantSemanticError> {
149        Ok(Vec::new())
150    }
151
152    /// Selects the least durable recipient obligation for this incarnation,
153    /// restarting from durable ack when `offered` names an older binding.
154    ///
155    /// # Errors
156    ///
157    /// Returns a semantic fault when the durable obligation owner is unavailable.
158    fn next_publication(
159        &self,
160        _connection_incarnation: ConnectionIncarnation,
161        _conversation_id: ConversationId,
162        _offered: Option<ParticipantOfferedProgress>,
163    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
164        Ok(None)
165    }
166
167    /// Checks that a held head still belongs to the exact current binding before
168    /// it is offered after writable readiness.
169    ///
170    /// # Errors
171    ///
172    /// Returns a semantic fault when current binding authority cannot be read.
173    fn publication_binding_is_current(
174        &self,
175        _conversation_id: ConversationId,
176        _participant_id: ParticipantId,
177        _binding_epoch: BindingEpoch,
178    ) -> Result<bool, ParticipantSemanticError> {
179        Ok(false)
180    }
181
182    /// Records exact successful marker enqueue testimony. Non-marker offers are
183    /// ignored by production after validating their current binding.
184    ///
185    /// # Errors
186    ///
187    /// Returns a semantic fault when exact offer testimony cannot be recorded.
188    fn record_publication_offer(
189        &self,
190        _publication: &ParticipantPublication,
191    ) -> Result<(), ParticipantSemanticError> {
192        Ok(())
193    }
194
195    /// Applies observer recovery with the weak exact-live-connection target
196    /// captured by the installed service. Semantic-only handlers delegate to
197    /// their ordinary request path and do not own observer publication.
198    ///
199    /// # Errors
200    ///
201    /// Returns [`ParticipantSemanticError`] under the same contract as
202    /// [`Self::handle`].
203    fn handle_observer_recovery(
204        &self,
205        context: ParticipantConnectionContext,
206        conversations: &mut ParticipantConnectionConversations,
207        request: ObserverRecoveryHandshake,
208        target: Option<ObserverPublicationTarget>,
209    ) -> Result<ServerValue, ParticipantSemanticError> {
210        drop(target);
211        self.handle(
212            context,
213            conversations,
214            ClientRequest::ObserverRecovery(request),
215        )
216    }
217
218    /// Applies one decoded participant request to protocol-owned authority.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`ParticipantSemanticError`] when durable or protocol authority
223    /// cannot produce a truthful terminal value. The connection fails rather
224    /// than fabricating a response.
225    fn handle(
226        &self,
227        context: ParticipantConnectionContext,
228        conversations: &mut ParticipantConnectionConversations,
229        request: ClientRequest,
230    ) -> Result<ServerValue, ParticipantSemanticError>;
231}
232
233/// Server-sealed participant activation token installed on a connection
234/// supervisor.
235///
236/// The semantic handler and its durable store form one value so participant
237/// capability activation cannot observe one without the other. The supervisor
238/// uses the store to durably allocate connection incarnations before spawning a
239/// connection process, and the process uses the handler only after that exact
240/// incarnation has been carried into its state. The token atomically carries the
241/// pair declared by server composition; it does not independently prove storage
242/// namespace identity.
243///
244/// Construction and access are server-private. Until a complete production
245/// lifecycle handler exists, external [`ConnectionServices`](crate::server::connection::ConnectionServices)
246/// implementations cannot manufacture an activation token or advertise the
247/// participant capability.
248#[derive(Clone, Debug)]
249pub struct InstalledParticipantService {
250    handler: Arc<dyn ParticipantSemanticHandler>,
251    durable_store: Arc<dyn DurableStore>,
252    frame_limit: ValidatedFrameLimit,
253    publication_registry: Arc<ParticipantPublicationRegistry>,
254}
255
256impl InstalledParticipantService {
257    /// Pairs a semantic handler, its declared durable store, and the raw
258    /// configured participant wire-frame limit.
259    ///
260    /// Production construction happens exactly once, in the server's
261    /// connection-services layer, from the deployment's `[participant]`
262    /// configuration; tests construct it directly with fixture handlers.
263    ///
264    /// # Errors
265    ///
266    /// Returns the shared codec error when the configured limit is smaller than
267    /// the protocol's minimum complete frame.
268    pub(crate) fn new(
269        handler: Arc<dyn ParticipantSemanticHandler>,
270        durable_store: Arc<dyn DurableStore>,
271        configured_wf: u64,
272    ) -> Result<Self, CodecError> {
273        Ok(Self {
274            handler,
275            durable_store,
276            frame_limit: normalize_configured_frame_limit(configured_wf)?,
277            publication_registry: Arc::new(ParticipantPublicationRegistry::default()),
278        })
279    }
280
281    /// Clones the durable store used by the installed participant service.
282    #[must_use]
283    pub(crate) fn durable_store(&self) -> Arc<dyn DurableStore> {
284        Arc::clone(&self.durable_store)
285    }
286
287    /// Returns the normalized configured complete-frame limit advertised by
288    /// this installed participant service.
289    #[must_use]
290    pub(crate) const fn frame_limit(&self) -> ValidatedFrameLimit {
291        self.frame_limit
292    }
293
294    /// Returns the signed semantic-conversation allowance shared by publication
295    /// readiness and connection-held encoded heads.
296    #[must_use]
297    pub(crate) fn publication_conversation_limit(&self) -> u64 {
298        self.handler.publication_conversation_limit()
299    }
300
301    /// Creates the strongly connection-owned ready inbox at process spawn.
302    #[must_use]
303    pub(crate) fn new_publication_inbox(&self) -> ParticipantPublicationInbox {
304        ParticipantPublicationInbox::new(self.handler.publication_conversation_limit())
305    }
306
307    /// Returns the shared weak publication registry.
308    #[must_use]
309    pub(crate) fn publication_registry(&self) -> &ParticipantPublicationRegistry {
310        &self.publication_registry
311    }
312
313    /// Selects one exact durable publication through the installed production
314    /// source.
315    pub(crate) fn next_publication(
316        &self,
317        connection_incarnation: ConnectionIncarnation,
318        conversation_id: ConversationId,
319        offered: Option<ParticipantOfferedProgress>,
320    ) -> Result<Option<ParticipantPublication>, ParticipantSemanticError> {
321        self.handler
322            .next_publication(connection_incarnation, conversation_id, offered)
323    }
324
325    pub(crate) fn publication_binding_is_current(
326        &self,
327        conversation_id: ConversationId,
328        participant_id: ParticipantId,
329        binding_epoch: BindingEpoch,
330    ) -> Result<bool, ParticipantSemanticError> {
331        self.handler
332            .publication_binding_is_current(conversation_id, participant_id, binding_epoch)
333    }
334
335    pub(crate) fn record_publication_offer(
336        &self,
337        publication: &ParticipantPublication,
338    ) -> Result<(), ParticipantSemanticError> {
339        self.handler.record_publication_offer(publication)
340    }
341
342    fn notify_ready(
343        &self,
344        conversation_id: ConversationId,
345    ) -> Result<(), ParticipantSemanticError> {
346        for incarnation in self
347            .handler
348            .ready_connection_incarnations(conversation_id)?
349        {
350            self.publication_registry
351                .notify(incarnation, conversation_id)
352                .map_err(|error| ParticipantSemanticError::Internal {
353                    message: format!("participant publication wake failed: {error}"),
354                })?;
355        }
356        Ok(())
357    }
358}
359
360impl ParticipantSemanticHandler for InstalledParticipantService {
361    fn publication_conversation_limit(&self) -> u64 {
362        self.handler.publication_conversation_limit()
363    }
364
365    fn handle(
366        &self,
367        context: ParticipantConnectionContext,
368        conversations: &mut ParticipantConnectionConversations,
369        request: ClientRequest,
370    ) -> Result<ServerValue, ParticipantSemanticError> {
371        if let ClientRequest::ObserverRecovery(request) = request {
372            let target = self
373                .publication_registry
374                .observer_target(context.connection_incarnation())
375                .map_err(|error| ParticipantSemanticError::Internal {
376                    message: format!("observer publication target failed: {error}"),
377                })?;
378            return self
379                .handler
380                .handle_observer_recovery(context, conversations, request, target);
381        }
382        let conversation_id = request_conversation_id(&request);
383        let value = self.handler.handle(context, conversations, request)?;
384        if let Some(conversation_id) = conversation_id {
385            self.notify_ready(conversation_id)?;
386        }
387        Ok(value)
388    }
389}
390
391const fn request_conversation_id(request: &ClientRequest) -> Option<ConversationId> {
392    match request {
393        ClientRequest::Enrollment(request) => Some(request.conversation_id),
394        ClientRequest::CredentialAttach(request) => Some(request.conversation_id),
395        ClientRequest::Detach(request) => Some(request.conversation_id),
396        ClientRequest::ParticipantAck(request) => Some(request.conversation_id),
397        ClientRequest::Leave(request) => Some(request.conversation_id),
398        ClientRequest::MarkerAck(request) => Some(request.conversation_id),
399        ClientRequest::RecordAdmission(request) => Some(request.conversation_id),
400        ClientRequest::ObserverRecovery(_) => None,
401    }
402}
403
404/// Result of dispatching one generic frame through participant transport.
405#[derive(Debug)]
406pub enum ParticipantDispatch {
407    /// The generic frame belongs to another protocol.
408    NotParticipant,
409    /// Exact encoded response selected by the shared gate or semantic handler.
410    Respond(Frame),
411    /// Exact crate-owned pre-semantic rejection, followed by connection close.
412    RespondThenClose(Frame),
413    /// No truthful participant response exists; the connection must fail closed.
414    Fatal(ParticipantDispatchError),
415}
416
417/// Failure after a generic frame has entered participant dispatch.
418#[derive(Debug, thiserror::Error)]
419pub enum ParticipantDispatchError {
420    /// The preserved generic frame could not represent a canonical participant frame.
421    #[error("invalid generic participant frame")]
422    InvalidGenericFrame,
423    /// The semantic handler could not produce a protocol value.
424    #[error(transparent)]
425    Semantic(#[from] ParticipantSemanticError),
426    /// The crate-produced value could not be encoded into the generic transport.
427    #[error("failed to encode participant response: {0:?}")]
428    Encode(CodecError),
429}
430
431/// Gates, decodes, semantically applies, and encodes one participant frame.
432///
433/// Transport rejection values originate in `liminal-protocol`; semantic values
434/// originate only in `handler`. No lifecycle outcome is constructed here.
435#[must_use]
436pub fn dispatch_generic_frame(
437    frame: &Frame,
438    authenticated: bool,
439    session: ParticipantSession,
440    context: ParticipantConnectionContext,
441    conversations: &mut ParticipantConnectionConversations,
442    handler: &dyn ParticipantSemanticHandler,
443) -> ParticipantDispatch {
444    let (value, close_after_response) = match gate_generic_frame(frame, authenticated, session) {
445        ParticipantIngress::NotParticipant => return ParticipantDispatch::NotParticipant,
446        ParticipantIngress::Rejected(rejection) => {
447            (ServerValue::ParticipantTransportRejected(rejection), true)
448        }
449        ParticipantIngress::InvalidGenericFrame => {
450            return ParticipantDispatch::Fatal(ParticipantDispatchError::InvalidGenericFrame);
451        }
452        ParticipantIngress::Request(request) => {
453            match handler.handle(context, conversations, request) {
454                Ok(value) => (value, false),
455                Err(error) => {
456                    return ParticipantDispatch::Fatal(ParticipantDispatchError::Semantic(error));
457                }
458            }
459        }
460    };
461    match encode_server_value(value) {
462        Ok(frame) if close_after_response => ParticipantDispatch::RespondThenClose(frame),
463        Ok(frame) => ParticipantDispatch::Respond(frame),
464        Err(error) => ParticipantDispatch::Fatal(ParticipantDispatchError::Encode(error)),
465    }
466}