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