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