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