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