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