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