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