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