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