liminal_server/server/connection/services.rs
1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, OnceLock, RwLock, mpsc};
5use std::time::Instant;
6
7use haematite::{Database, DatabaseConfig, EventStore};
8use liminal::channel::{ChannelConfig, ChannelHandle, ChannelMode, ChannelSupervisor, Schema};
9use liminal::conversation::{
10 ConversationSupervisor, CrashPolicy, EchoBehaviour, ParticipantBehaviour,
11};
12use liminal::durability::bridge::block_on;
13use liminal::durability::{
14 DedupCache, DedupDecision, DurabilityError, DurableStore, EphemeralHaematiteStore,
15 HaematiteStore, ProcessingReceipt, open_ephemeral,
16};
17use liminal::protocol::{MessageEnvelope, ProtocolError, SchemaId as ProtocolSchemaId};
18
19use super::channel_registry::{
20 ChannelAccessError, ChannelBuildError, ChannelConfigField, ChannelDescriptor, ChannelOrigin,
21 ChannelRegistration, ChannelRegistryError, ChannelState, ChannelStatus, MAX_CHANNELS_KEY,
22 Registered, STATE_ACTIVE, STATE_QUIESCED, UNRECORDED_QUIESCE_REASON,
23};
24use super::conversation::{ConnectionConversation, LiminalConversationResource};
25use super::services_cluster::build_channel_cluster;
26use super::services_schema::{ChannelSchema, resolve_channel_schema, resolve_schema_bytes};
27use super::worker_front_door::WorkerFrontDoorServices;
28use crate::ServerError;
29use crate::config::types::{ClusterConfig, ServerConfig, ServiceProfile};
30use crate::health::reissue::OperatorCredentialReissuer;
31use crate::health::unloadable::UnloadableConversationRecord;
32use crate::server::participant::{InstalledParticipantService, ProductionParticipantHandler};
33
34pub use super::services_cluster::ChannelCluster;
35
36/// Registry of custom conversation responders, keyed by conversation subject.
37///
38/// A registered [`ParticipantBehaviour`] becomes the participant for any
39/// conversation opened on its subject; subjects with no entry fall back to the
40/// built-in [`EchoBehaviour`].
41type ResponderRegistry = HashMap<String, Arc<dyn ParticipantBehaviour>>;
42
43/// Marker for resources retained by a connection process until unsubscribe.
44pub trait SubscriptionResource: std::fmt::Debug + Send {
45 /// Releases the library subscription resource.
46 ///
47 /// # Errors
48 /// Returns [`ServerError`] when the liminal library reports an unsubscribe failure.
49 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError>;
50
51 /// Attempts to pull the next delivered envelope from the wrapped library
52 /// subscription without blocking.
53 ///
54 /// Returns `None` when the subscriber inbox is empty (or momentarily
55 /// unavailable): the connection process is the delivery pump, so a transient
56 /// empty read is simply "nothing to deliver this slice", never an error.
57 fn try_next(&mut self) -> Option<liminal::envelope::Envelope>;
58
59 /// Non-consuming availability query for the post-arm race barrier.
60 fn has_pending(&self) -> bool;
61
62 /// Whether an overflow has marked this subscription for shedding (§5). The
63 /// delivery pump sheds an overflowed subscription with a typed error frame.
64 /// Defaulted to `false`: a resource with no bounded inbox never overflows.
65 fn is_overflowed(&self) -> bool {
66 false
67 }
68}
69
70/// Library subscription resource owned by a single connection process.
71#[derive(Debug)]
72pub struct ConnectionSubscription {
73 id: u64,
74 /// Channel this subscription was opened on, carried for DIAGNOSTICS only —
75 /// the shed warning has to name the channel or an operator reading it learns
76 /// that something was shed without learning what. Empty when unset (the
77 /// scheduler-free unit fixtures, which construct through `new` and never log).
78 channel: String,
79 /// Client-chosen application stream the server delivers this subscription's
80 /// messages on (echoed on `SubscribeAck`, carried on every `Deliver`). Set by
81 /// the connection process from the `Subscribe` frame before the subscription is
82 /// stored; `0` only while momentarily unset during construction.
83 stream_id: u32,
84 selected_schema: ProtocolSchemaId,
85 resource: Box<dyn SubscriptionResource>,
86}
87
88impl ConnectionSubscription {
89 /// Creates an owned subscription resource for one connection process.
90 #[must_use]
91 pub fn new(
92 id: u64,
93 selected_schema: ProtocolSchemaId,
94 resource: Box<dyn SubscriptionResource>,
95 ) -> Self {
96 Self {
97 id,
98 channel: String::new(),
99 stream_id: 0,
100 selected_schema,
101 resource,
102 }
103 }
104
105 /// Records the channel this subscription was opened on (diagnostics).
106 ///
107 /// Additive setter rather than a fourth `new` parameter: `new` is public API
108 /// and its existing callers keep compiling byte-for-byte.
109 pub(super) fn set_channel(&mut self, channel: String) {
110 self.channel = channel;
111 }
112
113 /// The channel this subscription was opened on, or `""` when unset.
114 #[must_use]
115 pub(super) fn channel(&self) -> &str {
116 &self.channel
117 }
118
119 /// Returns the protocol subscription id.
120 #[must_use]
121 pub const fn id(&self) -> u64 {
122 self.id
123 }
124
125 /// Records the client-chosen delivery stream id for this subscription.
126 pub(super) const fn set_stream_id(&mut self, stream_id: u32) {
127 self.stream_id = stream_id;
128 }
129
130 /// Returns the client-chosen application stream id deliveries ride on.
131 #[must_use]
132 pub(super) const fn stream_id(&self) -> u32 {
133 self.stream_id
134 }
135
136 /// Returns the schema selected for this subscription stream.
137 #[must_use]
138 pub const fn selected_schema(&self) -> ProtocolSchemaId {
139 self.selected_schema
140 }
141
142 /// Attempts to pull the next delivered envelope without blocking.
143 pub(super) fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
144 self.resource.try_next()
145 }
146
147 pub(super) fn has_pending(&self) -> bool {
148 self.resource.has_pending()
149 }
150
151 /// Whether this subscription has been shed by an inbox overflow (§5).
152 pub(super) fn is_overflowed(&self) -> bool {
153 self.resource.is_overflowed()
154 }
155
156 pub(super) fn unsubscribe(self) -> Result<(), ServerError> {
157 self.resource.unsubscribe()
158 }
159}
160
161/// Outcome of a server publish.
162///
163/// Carries the assigned message id plus a genuine delivery ack (`delivered` = the
164/// message was accepted by at least one live subscriber on this publish, after any
165/// dedup-on-delivery suppression).
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub struct PublishOutcome {
168 /// Monotonic message id assigned to the accepted publish.
169 pub message_id: u64,
170 /// Whether the message was genuinely delivered to a subscriber. `false` means
171 /// the publish was accepted but reached no subscriber (empty channel) or was
172 /// a duplicate suppressed by dedup-on-delivery.
173 pub delivered: bool,
174}
175
176/// Which channel operation is asking the roster for admission.
177///
178/// `Copy`, so the hot path allocates nothing to ask. The contrast is
179/// [`ServerError::UnsupportedOperation`]'s owned `operation: String`: that one is
180/// built on a refusal path and pays for its allocation once per refusal, while
181/// this one is consulted on every publish and every subscribe frame that reaches
182/// the connection process.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub enum ChannelOperation {
185 /// A `Publish` frame is asking.
186 Publish,
187 /// A `Subscribe` frame is asking.
188 Subscribe,
189}
190
191/// Operations that adapt wire frames to liminal library calls.
192pub trait ConnectionServices: std::fmt::Debug + Send + Sync {
193 /// Returns the complete participant service installed on this adapter.
194 ///
195 /// `None` keeps participant capability disabled even when the adapter owns a
196 /// durable store for unrelated channel traffic. The returned token is
197 /// server-sealed and atomically carries declared semantics plus durability,
198 /// making a handler-without-store activation impossible by construction.
199 fn participant_service(&self) -> Option<InstalledParticipantService> {
200 None
201 }
202
203 /// Delegates a publish request to the liminal library.
204 ///
205 /// `idempotency_key`, when `Some`, drives dedup-on-delivery: a re-publish with
206 /// the same key is delivered to subscribers at most once. The returned
207 /// [`PublishOutcome`] carries the genuine delivery ack.
208 ///
209 /// # Errors
210 /// Returns [`ServerError`] when the liminal publish operation fails.
211 fn publish(
212 &self,
213 channel: &str,
214 envelope: &MessageEnvelope,
215 idempotency_key: Option<&str>,
216 ) -> Result<PublishOutcome, ServerError>;
217
218 /// Delegates a subscribe request to the liminal library.
219 ///
220 /// `install`, when `Some`, carries the connection's §5 shared inbox byte
221 /// budget, per-inbox fairness cap, and R3 wake notifier. The implementation
222 /// MUST install it on the subscription's inbox BEFORE the registration is
223 /// published to the channel actor (i.e. before any envelope can be
224 /// delivered), so no envelope is ever admitted uncharged, past the depth
225 /// cap, or without a wake. Implementations with no real inbox (test
226 /// stand-ins, capability-scoped profiles that refuse subscribe) may ignore
227 /// it.
228 ///
229 /// # Errors
230 /// Returns [`ServerError`] when the liminal subscribe operation fails.
231 fn subscribe(
232 &self,
233 channel: &str,
234 accepted_schemas: &[ProtocolSchemaId],
235 install: Option<liminal::channel::InboxInstall>,
236 ) -> Result<ConnectionSubscription, ServerError>;
237
238 /// Delegates unsubscribe to the liminal library.
239 ///
240 /// # Errors
241 /// Returns [`ServerError`] when the liminal unsubscribe operation fails.
242 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError>;
243
244 /// Delegates conversation open to the liminal library.
245 ///
246 /// # Errors
247 /// Returns [`ServerError`] when the liminal conversation open operation fails.
248 fn open_conversation(
249 &self,
250 conversation_id: u64,
251 subject: &str,
252 ) -> Result<ConnectionConversation, ServerError>;
253
254 /// Delegates a conversation message to the liminal library.
255 ///
256 /// # Errors
257 /// Returns [`ServerError`] when the liminal conversation message operation fails.
258 fn conversation_message(
259 &self,
260 conversation: &ConnectionConversation,
261 envelope: &MessageEnvelope,
262 op_id: Option<u64>,
263 ) -> Result<(), ServerError>;
264
265 /// Delegates conversation close to the liminal library.
266 ///
267 /// # Errors
268 /// Returns [`ServerError`] when the liminal conversation close operation fails.
269 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError>;
270
271 /// Flushes durable channel state through the liminal library boundary.
272 ///
273 /// # Errors
274 /// Returns [`ServerError`] when the liminal channel flush operation fails.
275 fn flush_durable_state(&self) -> Result<(), ServerError>;
276
277 /// Whether this adapter backs ordinary channel and conversation operations.
278 ///
279 /// The default is `true` — the full-service adapter serves publish, subscribe,
280 /// and conversation frames, so full mode is byte-for-byte unchanged. The
281 /// capability-scoped worker front door overrides this to `false`, letting
282 /// [`super::apply`] reject the channel/conversation frames it short-circuits on
283 /// empty connection state (`Unsubscribe`, `ConversationMessage`,
284 /// `ConversationClose`) with a typed error frame instead of silently swallowing
285 /// an operation for a resource that could never have been created in this
286 /// profile. Frames that always reach a service method (`Publish`, `Subscribe`,
287 /// `ConversationOpen`) are rejected by the front door's own method bodies and do
288 /// not consult this flag.
289 fn supports_channel_operations(&self) -> bool {
290 true
291 }
292
293 /// Whether `channel` admits `operation` right now.
294 ///
295 /// Consulted by the connection process BEFORE the operation is delegated, so
296 /// a roster refusal is typed at the moment of the decision, by the component
297 /// that made it, from the value it decided on. The alternative — classifying
298 /// an opaque failure afterwards by re-reading the roster in the error arm —
299 /// cannot tell "the roster refused this" from "something else failed while
300 /// the roster happened to change", and would put a confident wrong cause on
301 /// the wire.
302 ///
303 /// It does NOT replace the adapter's own inner check. Admission here is the
304 /// caller's guard; a service method is public and callable without a frame,
305 /// so it keeps its own.
306 ///
307 /// The default ADMITS. An adapter with no roster has nothing to say here and
308 /// its refusals travel as service errors exactly as they do today; only the
309 /// roster-owning adapter overrides this. A default body is also what keeps
310 /// this addition inside "minor": a method added without one breaks every
311 /// downstream implementor of a public trait.
312 ///
313 /// # Errors
314 /// Returns [`ChannelAccessError`] when the roster refuses the operation.
315 fn admit_channel(
316 &self,
317 operation: ChannelOperation,
318 channel: &str,
319 ) -> Result<(), ChannelAccessError> {
320 let _ = (operation, channel);
321 Ok(())
322 }
323}
324
325/// Default adapter from server wire frames to liminal channel/conversation APIs.
326#[derive(Debug)]
327pub struct LiminalConnectionServices {
328 /// The channel roster, keyed by channel name.
329 ///
330 /// Behind an [`RwLock`] over `Arc` values so a reader clones one pointer out
331 /// under the guard and works with it after the guard is released: no roster
332 /// read is ever held across a library call, and an entry handed out can
333 /// outlive a concurrent mutation of the map itself.
334 channels: RwLock<HashMap<String, Arc<ConfiguredChannel>>>,
335 /// The operator-declared bound on RUNTIME-registered channels
336 /// (`limits.max_channels`), carried verbatim from config.
337 ///
338 /// `None` is "the operator declared no bound", which refuses every runtime
339 /// registration rather than admitting an unbounded one — the roster's
340 /// aggregate size is otherwise unbounded the moment a registration API
341 /// exists. Boot-configured channels are never counted against it: they are
342 /// the bound the operator already wrote, in the file they wrote it in.
343 max_channels: Option<usize>,
344 cluster: ChannelCluster,
345 durable_store: Arc<dyn DurableStore>,
346 /// Complete participant service, installed only when semantic lifecycle
347 /// handling and its durable aggregate store are both ready.
348 participant_service: Option<InstalledParticipantService>,
349 /// The production handler's refused-load record, captured at construction.
350 ///
351 /// [`InstalledParticipantService`] holds its handler behind
352 /// `dyn ParticipantSemanticHandler`, so the concrete production handler is
353 /// no longer reachable once it is installed; the record is taken here, in
354 /// the one place the concrete handler exists, and carried out to the
355 /// startup path that publishes it onto the health endpoint. `None` is "no
356 /// participant is configured", which the operator surface reports as such
357 /// rather than as an empty refusal set.
358 unloadable_conversations: Option<UnloadableConversationRecord>,
359 /// The production handler as the A7 operator credential-re-issue authority
360 /// (§0.18), captured at construction for exactly the reason above: the
361 /// concrete handler is unreachable once erased behind
362 /// `dyn ParticipantSemanticHandler`, so the operator surface's handle on it
363 /// has to be taken in the one place it still exists. `None` is "no
364 /// participant is configured", which the route reports as such.
365 credential_reissuer: Option<Arc<dyn OperatorCredentialReissuer>>,
366 /// In-memory (haematite-backed) dedup cache for dedup-on-delivery. Keyed by
367 /// the per-message idempotency key carried on the publish frame; a duplicate
368 /// key is suppressed before fan-out so a subscriber receives it at most once.
369 /// Not persisted across restarts (13-L1 scope; durable dedup is deferred).
370 dedup: DedupCache,
371 conversation_supervisor: Arc<ConversationSupervisor>,
372 /// Registered custom conversation responders, keyed by conversation subject.
373 ///
374 /// When a conversation is opened (`open_conversation`), the subject is looked
375 /// up here: a registered [`ParticipantBehaviour`] becomes the conversation's
376 /// participant; with no registration the conversation falls back to the
377 /// built-in [`EchoBehaviour`], preserving the original echo semantics exactly.
378 /// This is the seam aion #13 plugs a remote worker responder into. Interior
379 /// mutability is required because the services are shared behind `&self`.
380 responders: Mutex<ResponderRegistry>,
381 next_message_id: AtomicU64,
382 next_subscription_id: AtomicU64,
383}
384
385impl LiminalConnectionServices {
386 /// Builds library-backed services from validated server configuration.
387 ///
388 /// Durable-mode channels are backed by a shared haematite event store so
389 /// their publishes are persisted and survive the graceful-shutdown flush;
390 /// ephemeral channels carry no store.
391 ///
392 /// Full-only: a config selecting the worker-front-door profile is rejected at
393 /// entry (before any store is built), so this constructor can never build full
394 /// services for a profile that forbids them. Profile-aware callers go through
395 /// [`build_connection_services`] instead.
396 ///
397 /// # Errors
398 /// Returns [`ServerError`] when the config selects a non-full profile or a
399 /// configured channel cannot be initialized.
400 pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
401 require_full_profile(config)?;
402 let store = ProductionSubsystems.durable_store(config.persistence_path.as_deref())?;
403 Self::from_config_with_store_via(config, store, &ProductionSubsystems)
404 }
405
406 /// Builds services over a caller-provided durable store.
407 ///
408 /// Used by tests that need to inspect persisted state through the same store
409 /// handle the durable channels write to.
410 ///
411 /// Full-only: rejects a worker-front-door profile at entry, exactly like
412 /// [`Self::from_config`].
413 ///
414 /// # Errors
415 /// Returns [`ServerError`] when the config selects a non-full profile or a
416 /// configured channel cannot be initialized.
417 pub fn from_config_with_store(
418 config: &ServerConfig,
419 durable_store: Arc<dyn DurableStore>,
420 ) -> Result<Self, ServerError> {
421 require_full_profile(config)?;
422 Self::from_config_with_store_via(config, durable_store, &ProductionSubsystems)
423 }
424
425 /// [`Self::from_config_with_store`] with the subsystem factory injected.
426 ///
427 /// The channel supervisor and conversation supervisor are constructed ONLY
428 /// through `subsystems` — there is no direct constructor call in this body —
429 /// so a factory that records as a side effect of constructing cannot have its
430 /// recording omitted (§9 D2 seam census, record-by-construction). No profile
431 /// check here: the caller (the public wrapper or the profile dispatch in
432 /// [`build_connection_services`]) has already established the full profile.
433 fn from_config_with_store_via(
434 config: &ServerConfig,
435 durable_store: Arc<dyn DurableStore>,
436 subsystems: &dyn SubsystemFactory,
437 ) -> Result<Self, ServerError> {
438 // Build ONE shared channel supervisor for the whole server. When a
439 // [cluster] section is present it is distribution-enabled, so every
440 // channel actor and subscriber shares the clustered scheduler the cluster
441 // attaches its process-group transport to (SRV-005, Constraint B).
442 let cluster = subsystems.channel_cluster(config.cluster.as_ref())?;
443 let mut channels = HashMap::new();
444 for channel in &config.channels {
445 // Resolve the channel's real JSON Schema (loaded from `schema_ref`
446 // during config validation) or the permissive empty schema when the
447 // channel declared none. The protocol schema id advertised at
448 // subscribe time is derived from the SAME schema bytes so an SDK
449 // deriving ids from schema bytes converges on it.
450 let resolved = resolve_channel_schema(channel);
451 let configured = build_configured_channel(
452 &channel.name,
453 resolved,
454 channel.durable,
455 ChannelOrigin::BootConfigured,
456 &durable_store,
457 cluster.supervisor(),
458 )
459 .map_err(|error| ServerError::ConfigValidation {
460 message: error.boot_message(&channel.name),
461 })?;
462 channels.insert(channel.name.clone(), Arc::new(configured));
463 }
464 let conversation_supervisor = subsystems.conversation_supervisor()?;
465 let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
466 // Production participant activation (LP gap closure, Part B): the
467 // deployment's [participant] section installs the ONE production
468 // semantic handler, sealed together with the same durable store the
469 // conversation logs live in, under the configured wire-frame limit.
470 // No section, no service — the capability bit stays off and the
471 // connection path is byte-identical to the pre-activation build.
472 let installed_participant = config
473 .participant
474 .as_ref()
475 .map(|participant| {
476 let handler =
477 ProductionParticipantHandler::new(Arc::clone(&durable_store), *participant)
478 .map_err(|error| ServerError::ParticipantStartupRestore {
479 message: error.to_string(),
480 })?;
481 // Taken BEFORE the handler is erased behind
482 // `dyn ParticipantSemanticHandler`: boot has already recorded
483 // every conversation it refused by the time `new` returns, so
484 // the record captured here is complete from the first scrape.
485 let unloadable_conversations = handler.unloadable_record();
486 let handler = Arc::new(handler);
487 // A7 (§0.18): taken here for the same reason, and from the same
488 // one live handler, so the operator surface and the participant
489 // wire cannot end up talking to two different owners.
490 let credential_reissuer: Arc<dyn OperatorCredentialReissuer> =
491 Arc::<ProductionParticipantHandler>::clone(&handler);
492 let service = InstalledParticipantService::new(
493 handler,
494 Arc::clone(&durable_store),
495 participant.wire_frame_limit,
496 )
497 .map_err(|error| ServerError::ConfigValidation {
498 message: format!(
499 "participant.wire_frame_limit: {} is below the protocol's minimum \
500 complete participant frame ({error:?})",
501 participant.wire_frame_limit
502 ),
503 })?;
504 Ok::<_, ServerError>((
505 service,
506 (unloadable_conversations, credential_reissuer),
507 ))
508 })
509 .transpose()?;
510 let (participant_service, operator_surfaces) = installed_participant.unzip();
511 let (unloadable_conversations, credential_reissuer) = operator_surfaces.unzip();
512 Ok(Self {
513 channels: RwLock::new(channels),
514 max_channels: config.limits.max_channels,
515 cluster,
516 durable_store,
517 participant_service,
518 unloadable_conversations,
519 credential_reissuer,
520 dedup,
521 conversation_supervisor,
522 responders: Mutex::new(HashMap::new()),
523 next_message_id: AtomicU64::new(1),
524 next_subscription_id: AtomicU64::new(1),
525 })
526 }
527
528 /// Builds services with no configured channels.
529 ///
530 /// # Errors
531 /// Returns [`ServerError`] when the conversation supervisor scheduler cannot start.
532 pub fn empty() -> Result<Self, ServerError> {
533 let conversation_supervisor = ProductionSubsystems.conversation_supervisor()?;
534 let durable_store = build_durable_store(None)?;
535 let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
536 Ok(Self {
537 channels: RwLock::new(HashMap::new()),
538 // No config, so no declared bound: this builder serves tests and
539 // callers with no channels at all, and a registration against it
540 // refuses `CapNotConfigured` exactly as an undeclared deployment's
541 // would.
542 max_channels: None,
543 cluster: build_channel_cluster(None)?,
544 durable_store,
545 participant_service: None,
546 unloadable_conversations: None,
547 credential_reissuer: None,
548 dedup,
549 conversation_supervisor,
550 responders: Mutex::new(HashMap::new()),
551 next_message_id: AtomicU64::new(1),
552 next_subscription_id: AtomicU64::new(1),
553 })
554 }
555
556 /// The shared channel supervisor + cluster resolver backing this service.
557 ///
558 /// The server runtime uses this to attach the cluster to the channel
559 /// supervisor's clustered scheduler (SRV-005).
560 #[must_use]
561 pub const fn channel_cluster(&self) -> &ChannelCluster {
562 &self.cluster
563 }
564
565 /// Returns the shared durable store backing this service's durable channels.
566 #[must_use]
567 pub fn durable_store(&self) -> Arc<dyn DurableStore> {
568 Arc::clone(&self.durable_store)
569 }
570
571 /// The production participant handler's refused-load record, when a
572 /// participant is configured.
573 ///
574 /// The server's startup path publishes this onto the health endpoint so
575 /// `GET /unloadable-conversations` answers from the same record the
576 /// handler writes. `None` means no participant is configured at all, which
577 /// the surface reports as a distinct state from "nothing was refused".
578 #[must_use]
579 pub fn unloadable_conversation_record(&self) -> Option<UnloadableConversationRecord> {
580 self.unloadable_conversations.clone()
581 }
582
583 /// The A7 operator credential-re-issue authority, when a participant is
584 /// configured (§0.18).
585 ///
586 /// The startup path publishes this onto the health endpoint so
587 /// `POST /operator/credential-reissue` reaches the SAME serialized
588 /// participant-state point every wire operation does. `None` means no
589 /// participant is configured, which the route reports as a distinct state
590 /// from an unknown identity.
591 #[must_use]
592 pub fn credential_reissuer(&self) -> Option<Arc<dyn OperatorCredentialReissuer>> {
593 self.credential_reissuer.clone()
594 }
595
596 /// Installs a complete participant bundle in full-service supervisor tests.
597 ///
598 /// Production full services intentionally stay disabled until a concrete
599 /// lifecycle handler exists. This consuming test builder exercises the real
600 /// supervisor activation path without allowing an already-shared adapter to
601 /// change capability posture.
602 #[cfg(test)]
603 #[must_use]
604 pub(crate) fn with_participant_service(
605 mut self,
606 participant_service: InstalledParticipantService,
607 ) -> Self {
608 self.participant_service = Some(participant_service);
609 self
610 }
611
612 /// Returns the conversation supervisor backing supervised conversations.
613 ///
614 /// Tests use this to reach the underlying beamr scheduler so they can spawn
615 /// or terminate participant processes and exercise crash detection.
616 #[must_use]
617 pub fn conversation_supervisor(&self) -> Arc<ConversationSupervisor> {
618 Arc::clone(&self.conversation_supervisor)
619 }
620
621 /// Registers a custom conversation responder for a routing `subject`.
622 ///
623 /// When a conversation is later opened with this exact `subject`, its
624 /// participant runs `behaviour` instead of the built-in [`EchoBehaviour`].
625 /// The responder is spawned and supervised identically to the echo
626 /// participant — a real linked beamr process with the same crash-detection
627 /// semantics — so this exposes the responder seam without changing how
628 /// participants run. Registering a subject that already has a responder
629 /// replaces it; the previous behaviour is returned.
630 ///
631 /// This is the liminal-side seam aion #13 plugs a remote worker into: it
632 /// registers a responder that forwards each request to the worker and routes
633 /// the worker's reply back through the conversation. Subjects with no
634 /// registration keep echoing, so existing callers are unaffected.
635 ///
636 /// # Errors
637 /// Returns [`ServerError`] when the responder registry lock is poisoned.
638 pub fn register_responder(
639 &self,
640 subject: impl Into<String>,
641 behaviour: Arc<dyn ParticipantBehaviour>,
642 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
643 let mut responders = self.lock_responders()?;
644 Ok(responders.insert(subject.into(), behaviour))
645 }
646
647 /// Removes the custom responder registered for `subject`, if any.
648 ///
649 /// After removal the subject reverts to the built-in [`EchoBehaviour`] on the
650 /// next [`Self::open_conversation`]. Returns the removed behaviour when one
651 /// was registered.
652 ///
653 /// # Errors
654 /// Returns [`ServerError`] when the responder registry lock is poisoned.
655 pub fn unregister_responder(
656 &self,
657 subject: &str,
658 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
659 let mut responders = self.lock_responders()?;
660 Ok(responders.remove(subject))
661 }
662
663 /// Resolves the responder behaviour for `subject`: the registered custom
664 /// responder when present, otherwise the built-in [`EchoBehaviour`].
665 ///
666 /// This is the single routing decision behind the seam — registered-or-echo —
667 /// so the fallback is identical to the original hard-wired echo path.
668 fn responder_for(&self, subject: &str) -> Result<Arc<dyn ParticipantBehaviour>, ServerError> {
669 let responders = self.lock_responders()?;
670 Ok(responders.get(subject).map_or_else(
671 || Arc::new(EchoBehaviour) as Arc<dyn ParticipantBehaviour>,
672 Arc::clone,
673 ))
674 }
675
676 /// Locks the responder registry, mapping a poisoned lock to a [`ServerError`]
677 /// rather than panicking (the workspace denies `unwrap`/`expect`/`panic`).
678 fn lock_responders(&self) -> Result<std::sync::MutexGuard<'_, ResponderRegistry>, ServerError> {
679 self.responders
680 .lock()
681 .map_err(|_poisoned| ServerError::ListenerAccept {
682 message: "responder registry lock poisoned".to_owned(),
683 })
684 }
685
686 /// Takes the channel roster's read guard, mapping a poisoned lock to a
687 /// [`ServerError`] rather than panicking (the workspace denies
688 /// `unwrap`/`expect`/`panic`), exactly as [`Self::lock_responders`] does for
689 /// the responder registry.
690 ///
691 /// Every caller clones the `Arc` it needs out of the returned guard and drops
692 /// the guard before doing anything else: the roster lock is never held across
693 /// a call into the liminal library.
694 fn read_channels(
695 &self,
696 ) -> Result<std::sync::RwLockReadGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>, ServerError>
697 {
698 self.channels
699 .read()
700 .map_err(|_poisoned| ServerError::ListenerAccept {
701 message: "channel roster lock poisoned".to_owned(),
702 })
703 }
704
705 /// Subscribes to a configured channel and returns the raw library
706 /// subscription handle so a test can drain the subscriber inbox directly and
707 /// observe exactly which messages reached a subscriber.
708 #[cfg(test)]
709 pub(crate) fn subscribe_handle_for_test(
710 &self,
711 channel: &str,
712 ) -> Result<liminal::channel::SubscriptionHandle, ServerError> {
713 let channels = self.read_channels()?;
714 let configured =
715 channels
716 .get(channel)
717 .map(Arc::clone)
718 .ok_or_else(|| ServerError::ListenerAccept {
719 message: format!("channel '{channel}' is not configured"),
720 })?;
721 drop(channels);
722 configured
723 .handle
724 .subscribe()
725 .map_err(|error| ServerError::ListenerAccept {
726 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
727 })
728 }
729
730 /// Claims the delivery right for an idempotency key.
731 ///
732 /// Returns `Ok(true)` when this is the first publish for the key (the caller
733 /// may deliver), and `Ok(false)` when the key was already claimed/completed (a
734 /// duplicate the caller must suppress). The dedup cache is driven synchronously
735 /// over the in-memory haematite store via the durable bridge.
736 fn claim_delivery(&self, key: &str) -> Result<bool, ServerError> {
737 let decision = block_on(self.dedup.claim_or_get(key, dedup_timestamp_millis()))
738 .map_err(|error| ServerError::ListenerAccept {
739 message: format!("dedup bridge failed for key '{key}': {error}"),
740 })?
741 .map_err(|error| ServerError::ListenerAccept {
742 message: format!("dedup claim failed for key '{key}': {error}"),
743 })?;
744 Ok(matches!(decision, DedupDecision::Claimed))
745 }
746
747 /// Releases a dangling in-flight dedup claim after a failed delivery.
748 ///
749 /// Best-effort: a release failure cannot mask the original publish error, so
750 /// this returns nothing and logs at `error` level instead of surfacing. It is
751 /// never silent — the leak (a permanently suppressed key) must be observable.
752 /// `release_claim` itself never clobbers a stored receipt, so calling it on the
753 /// failure path is safe even if a concurrent completion raced ahead.
754 fn release_claim(&self, key: &str) {
755 match block_on(self.dedup.release_claim(key)) {
756 Ok(Ok(())) => {}
757 Ok(Err(error)) => {
758 tracing::error!(
759 idempotency_key = key,
760 %error,
761 "failed to release dedup claim after publish failure; key may stay suppressed"
762 );
763 }
764 Err(error) => {
765 tracing::error!(
766 idempotency_key = key,
767 %error,
768 "dedup release bridge failed after publish failure; key may stay suppressed"
769 );
770 }
771 }
772 }
773}
774
775/// The runtime channel-registration surface.
776///
777/// Inherent methods, not trait methods, and deliberately so: this is
778/// authority-moving vocabulary that belongs to the ONE adapter owning a channel
779/// roster. Putting `register`/`quiesce` on the public [`ConnectionServices`]
780/// trait would break every external implementor and would hand
781/// register/quiesce words to a profile that serves no channels at all.
782///
783/// The same seam as the existing runtime-mutation API on this type
784/// (`register_responder`/`unregister_responder`): `&self`, interior mutability,
785/// typed `Result`.
786impl LiminalConnectionServices {
787 /// Registers `spec` on the live roster.
788 ///
789 /// Idempotent when an entry of that name already has an IDENTICAL
790 /// configuration — mode, protocol schema id, and schema document, all three
791 /// — and refuses typed, naming the first differing field, otherwise. An
792 /// identical registration against a boot-configured entry answers
793 /// [`Registered::AlreadyIdentical`] and leaves its origin alone: flipping it
794 /// would make the entry lie about its restart fate and would move it into
795 /// the counted population without a channel having been created.
796 ///
797 /// The cap is consulted first, in two steps with different reach. An absent
798 /// `limits.max_channels` refuses EVERY call, identical or not, before the
799 /// roster is read at all: a deployment that has declared no bound has not
800 /// said what it admits, and unbounded-by-default is not a bound. A REACHED
801 /// cap refuses only a call that would create an entry — it gates the insert,
802 /// which is the population it bounds. An identical re-registration inserts
803 /// nothing and so is answered `AlreadyIdentical` even at a full roster;
804 /// refusing it would break idempotency at exactly the boundary a projector
805 /// re-projecting its record crosses.
806 ///
807 /// # Errors
808 /// Returns [`ChannelRegistryError`] when no cap is configured, the cap is
809 /// reached, the name exists with a different configuration, the schema bytes
810 /// do not parse or compile, durable initialization over the shared store
811 /// fails, or the roster lock is poisoned.
812 pub fn register_channel(
813 &self,
814 spec: &ChannelRegistration,
815 ) -> Result<Registered, ChannelRegistryError> {
816 // The cap is consulted before the roster is touched: an undeclared bound
817 // refuses every runtime registration, identical or not, because the
818 // deployment has not said what it admits.
819 let Some(limit) = self.max_channels else {
820 return Err(ChannelRegistryError::CapNotConfigured {
821 cap: MAX_CHANNELS_KEY,
822 });
823 };
824 // Schema resolution is pure (a JSON parse and a digest), so it runs with
825 // no lock held and its result serves BOTH the identity comparison and
826 // the construction below.
827 let resolved = resolve_schema_bytes(spec.schema_bytes.as_deref()).map_err(|error| {
828 ChannelRegistryError::SchemaRejected {
829 name: spec.name.clone(),
830 message: error.to_string(),
831 }
832 })?;
833
834 // Fast paths, under a READ lock: an already-identical registration
835 // builds nothing, and a full roster refuses before paying for a
836 // construction it would discard. Neither is the authority — the write
837 // lock below re-decides both, because the roster can move in between.
838 let (existing, registered_count) = {
839 let channels = self.read_roster()?;
840 let existing = channels.get(&spec.name).map(Arc::clone);
841 let registered_count = runtime_registered_count(&channels);
842 drop(channels);
843 (existing, registered_count)
844 };
845 if let Some(existing) = existing {
846 return compare_registration(&existing, spec, &resolved);
847 }
848 if registered_count >= limit {
849 return Err(ChannelRegistryError::CapReached {
850 cap: MAX_CHANNELS_KEY,
851 limit,
852 });
853 }
854
855 // Construction runs with NO lock held. A durable channel recovers its
856 // per-partition sequence counters from the store here, which is O(stream
857 // length) in store reads; holding the roster lock across it would put a
858 // slow store walk on every connection's publish and subscribe path.
859 let configured = build_configured_channel(
860 &spec.name,
861 resolved,
862 spec.durable,
863 ChannelOrigin::RuntimeRegistered,
864 &self.durable_store,
865 self.cluster.supervisor(),
866 )
867 .map_err(|error| error.into_registry_error(&spec.name))?;
868
869 // The authoritative decision: identity, cap, and insert under ONE write
870 // lock, so the count that admitted the entry and the insert that added
871 // it cannot be separated by a concurrent registration.
872 let mut channels = self.write_roster()?;
873 let raced = channels.get(&spec.name).map(Arc::clone);
874 let registered_count = runtime_registered_count(&channels);
875 if raced.is_none() && registered_count < limit {
876 channels.insert(spec.name.clone(), Arc::new(configured));
877 drop(channels);
878 return Ok(Registered::Created);
879 }
880 drop(channels);
881 if let Some(raced) = raced {
882 // A racer registered this name while the channel above was being
883 // built. The built entry is dropped unused: it owns no actor (the
884 // actor is spawned lazily on first use) and its durable
885 // construction only READ the store, so discarding it changes
886 // nothing an observer could see.
887 return compare_registration(&raced, spec, &schema_of(&configured));
888 }
889 Err(ChannelRegistryError::CapReached {
890 cap: MAX_CHANNELS_KEY,
891 limit,
892 })
893 }
894
895 /// Moves `name` from active to quiesced with a named `reason`. ONE-WAY.
896 ///
897 /// New publishes and new subscribes are refused afterwards, carrying the
898 /// reason. Existing subscriptions are UNTOUCHED: nothing revokes a
899 /// subscription handle, the actor's subscriber list is not walked, no EXIT
900 /// is sent, and the channel actor keeps running. Quiesce is a roster-level
901 /// admission decision, not an actor command.
902 ///
903 /// Re-quiescing under the IDENTICAL reason is `Ok(())`; a DIFFERENT reason
904 /// refuses, carrying the reason already on record.
905 ///
906 /// The return does NOT mean "no new subscriber can appear". A subscribe that
907 /// has already passed admission completes and gets its stream — the
908 /// linearisation point is the admission read, not the subscribe's
909 /// completion. A consumer that needs "nobody is attached" must observe
910 /// attachment directly.
911 ///
912 /// # Errors
913 /// Returns [`ChannelRegistryError`] when the name is not registered, is
914 /// already quiesced under a different reason, or the roster lock is
915 /// poisoned.
916 pub fn quiesce_channel(
917 &self,
918 name: &str,
919 reason: impl Into<String>,
920 ) -> Result<(), ChannelRegistryError> {
921 let reason = reason.into();
922 // A READ lock: the state machine lives on the ENTRY, not in the map, so
923 // the operation the design most wants to be safe never blocks a reader.
924 let configured = {
925 let channels = self.read_roster()?;
926 channels.get(name).map(Arc::clone).ok_or_else(|| {
927 ChannelRegistryError::NotRegistered {
928 name: name.to_owned(),
929 }
930 })?
931 };
932 configured.quiesce(name, &reason)
933 }
934
935 /// Cheap typed probe: one roster read plus one atomic load.
936 ///
937 /// Touches no actor and therefore CANNOT spawn one. Every handle accessor
938 /// that could answer a question about a channel's activity routes through
939 /// the lazy-spawn path, so a probe built on one would materialise the actor
940 /// of the idle channel it was asked about — turning a read into a side
941 /// effect. This reads the roster entry's own recorded fields and nothing
942 /// else.
943 ///
944 /// # Errors
945 /// Returns [`ChannelRegistryError::RosterUnavailable`] only.
946 pub fn channel_status(&self, name: &str) -> Result<ChannelStatus, ChannelRegistryError> {
947 let configured = {
948 let channels = self.read_roster()?;
949 channels.get(name).map(Arc::clone)
950 };
951 let Some(configured) = configured else {
952 return Ok(ChannelStatus::NotRegistered);
953 };
954 let mode = configured.handle.config().mode;
955 Ok(match configured.state() {
956 ChannelState::Active => ChannelStatus::Active {
957 origin: configured.origin,
958 mode,
959 schema: configured.protocol_schema,
960 },
961 ChannelState::Quiesced { reason } => ChannelStatus::Quiesced {
962 reason,
963 origin: configured.origin,
964 mode,
965 },
966 })
967 }
968
969 /// The whole roster: one minimal descriptor per entry, sorted by name.
970 ///
971 /// The census companion to [`Self::channel_status`]. A by-name probe answers
972 /// about a name the caller already suspects; only an enumeration can reveal
973 /// a name the caller does not know to ask about, and a verification sweep
974 /// with no population denominator is an instrument shape this estate
975 /// forbids. Touches no actor, under the same constraint as the probe.
976 ///
977 /// # Errors
978 /// Returns [`ChannelRegistryError::RosterUnavailable`] only.
979 pub fn registered_channels(&self) -> Result<Vec<ChannelDescriptor>, ChannelRegistryError> {
980 let entries: Vec<(String, Arc<ConfiguredChannel>)> = {
981 let channels = self.read_roster()?;
982 channels
983 .iter()
984 .map(|(name, configured)| (name.clone(), Arc::clone(configured)))
985 .collect()
986 };
987 let mut descriptors: Vec<ChannelDescriptor> = entries
988 .into_iter()
989 .map(|(name, configured)| ChannelDescriptor {
990 name,
991 origin: configured.origin,
992 state: configured.state(),
993 })
994 .collect();
995 descriptors.sort_by(|left, right| left.name.cmp(&right.name));
996 Ok(descriptors)
997 }
998
999 /// The roster admission funnel: the ONE place a channel operation's
1000 /// permission is decided.
1001 ///
1002 /// Returns the admitted entry, so the caller works from the value the
1003 /// decision was made on rather than reading the roster a second time and
1004 /// risking a different answer. This read is the LINEARISATION POINT for the
1005 /// quiesce race: everything the caller does with the returned entry happens
1006 /// outside the lock and is not re-checked, which is exactly why a quiesce
1007 /// that commits after this returns does not stop the operation it admitted.
1008 ///
1009 /// # Errors
1010 /// Returns [`ChannelAccessError`] when the channel is absent, quiesced, or
1011 /// the roster lock is poisoned.
1012 fn admit_channel(&self, channel: &str) -> Result<Arc<ConfiguredChannel>, ChannelAccessError> {
1013 let configured = {
1014 let channels = self.channels.read().map_err(|_poisoned| {
1015 ChannelAccessError::RosterUnavailable {
1016 message: ROSTER_POISONED.to_owned(),
1017 }
1018 })?;
1019 channels.get(channel).map(Arc::clone)
1020 };
1021 let configured = configured.ok_or_else(|| ChannelAccessError::NotRegistered {
1022 name: channel.to_owned(),
1023 })?;
1024 if configured.state.load(Ordering::Acquire) == STATE_QUIESCED {
1025 return Err(ChannelAccessError::Quiesced {
1026 name: channel.to_owned(),
1027 reason: configured.recorded_quiesce_reason(),
1028 });
1029 }
1030 Ok(configured)
1031 }
1032
1033 /// Takes the roster's READ guard for the registration surface, mapping a
1034 /// poisoned lock to a typed [`ChannelRegistryError`].
1035 ///
1036 /// Separate from [`Self::read_channels`] because the two surfaces answer
1037 /// different callers with different error types; recovering one from the
1038 /// other would mean inspecting a message.
1039 fn read_roster(
1040 &self,
1041 ) -> Result<
1042 std::sync::RwLockReadGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>,
1043 ChannelRegistryError,
1044 > {
1045 self.channels
1046 .read()
1047 .map_err(|_poisoned| ChannelRegistryError::RosterUnavailable {
1048 message: ROSTER_POISONED.to_owned(),
1049 })
1050 }
1051
1052 /// Takes the roster's WRITE guard — held only for the check-and-insert that
1053 /// must be atomic, never across a call into the liminal library.
1054 fn write_roster(
1055 &self,
1056 ) -> Result<
1057 std::sync::RwLockWriteGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>,
1058 ChannelRegistryError,
1059 > {
1060 self.channels
1061 .write()
1062 .map_err(|_poisoned| ChannelRegistryError::RosterUnavailable {
1063 message: ROSTER_POISONED.to_owned(),
1064 })
1065 }
1066}
1067
1068/// The diagnostic carried when the roster lock is poisoned. One string, shared
1069/// by every surface that reports it, so the wording cannot drift between them.
1070const ROSTER_POISONED: &str = "channel roster lock poisoned";
1071
1072/// How many roster entries the registration cap counts.
1073///
1074/// Runtime-registered entries ONLY. Boot-configured channels are the operator's
1075/// own authored bound — they are in the file the operator wrote — and counting
1076/// them would make one number mean two different things depending on how the
1077/// deployment was configured. The origin never flips, so this population is
1078/// well defined over time.
1079fn runtime_registered_count(channels: &HashMap<String, Arc<ConfiguredChannel>>) -> usize {
1080 channels
1081 .values()
1082 .filter(|configured| configured.origin == ChannelOrigin::RuntimeRegistered)
1083 .count()
1084}
1085
1086/// The three-field identity comparison behind idempotent-if-identical
1087/// registration.
1088///
1089/// All three must match; the FIRST mismatch is reported by name. The name itself
1090/// is not compared — it is the roster key, a precondition of the comparison
1091/// rather than a member of it — and neither are the supervisor or the durable
1092/// store, which are one server-wide instance each and cannot differ between two
1093/// registrations in one process.
1094///
1095/// The schema is compared as BOTH its protocol id and its parsed document, on
1096/// purpose. The id is a 64-bit non-cryptographic digest, so the document guards
1097/// against a collision accepting a different schema as identical; and the two
1098/// fail in opposite directions, because two byte sequences that parse to the
1099/// same document but differ in whitespace produce different ids — which must
1100/// refuse, since the id is what every future subscriber negotiates.
1101///
1102/// The channel's own `Schema` is not compared: it is not `PartialEq`, and its
1103/// identifier is a fresh value per construction, so comparing it would refuse
1104/// every idempotent re-registration.
1105fn compare_registration(
1106 existing: &ConfiguredChannel,
1107 spec: &ChannelRegistration,
1108 resolved: &ChannelSchema,
1109) -> Result<Registered, ChannelRegistryError> {
1110 let requested_mode = if spec.durable {
1111 ChannelMode::Durable
1112 } else {
1113 ChannelMode::Ephemeral
1114 };
1115 let config = existing.handle.config();
1116 let mismatch = if config.mode == requested_mode {
1117 if existing.protocol_schema == resolved.protocol_id {
1118 if *config.schema.definition() == resolved.document {
1119 None
1120 } else {
1121 Some(ChannelConfigField::SchemaDocument)
1122 }
1123 } else {
1124 Some(ChannelConfigField::SchemaId)
1125 }
1126 } else {
1127 Some(ChannelConfigField::Mode)
1128 };
1129 mismatch.map_or(Ok(Registered::AlreadyIdentical), |field| {
1130 Err(ChannelRegistryError::AlreadyRegistered {
1131 name: spec.name.clone(),
1132 field,
1133 })
1134 })
1135}
1136
1137/// Recovers the resolved schema of an already-built entry, so the write-lock
1138/// re-check compares the SAME three fields against the same values the fast path
1139/// would have.
1140fn schema_of(configured: &ConfiguredChannel) -> ChannelSchema {
1141 ChannelSchema {
1142 document: configured.handle.config().schema.definition().clone(),
1143 protocol_id: configured.protocol_schema,
1144 }
1145}
1146
1147/// Builds one roster entry: the channel's validation engine, its library handle
1148/// (durable or ephemeral), and the protocol schema id advertised at subscribe
1149/// time.
1150///
1151/// This is the SOLE place a [`ConfiguredChannel`] is constructed. Keeping
1152/// construction in one function is what makes "every channel on this server is
1153/// the same kind of object" structural rather than a promise: there is no second
1154/// body a channel could be built by, so no channel can drift into a different
1155/// shape. The boot loop in [`LiminalConnectionServices::from_config_with_store_via`]
1156/// is its caller.
1157///
1158/// `resolved` is consumed because its JSON Schema document is moved into the
1159/// channel's [`Schema`]; the protocol id is carried onto the entry unchanged.
1160/// `origin` is stamped here and never written again.
1161///
1162/// The failure is typed ([`ChannelBuildError`]) rather than a [`ServerError`] so
1163/// BOTH callers can render it without inspecting a message: the boot loop maps
1164/// it back to the exact `ConfigValidation` strings it has always produced, and
1165/// `register_channel` maps it to the registry's own typed variants.
1166///
1167/// # Errors
1168/// Returns [`ChannelBuildError`] when the JSON Schema document does not compile
1169/// or durable initialization over the shared store fails.
1170fn build_configured_channel(
1171 name: &str,
1172 resolved: ChannelSchema,
1173 durable: bool,
1174 origin: ChannelOrigin,
1175 durable_store: &Arc<dyn DurableStore>,
1176 supervisor: &ChannelSupervisor,
1177) -> Result<ConfiguredChannel, ChannelBuildError> {
1178 let schema =
1179 Schema::new(resolved.document).map_err(|error| ChannelBuildError::SchemaRejected {
1180 message: error.to_string(),
1181 })?;
1182 let channel_config = if durable {
1183 ChannelConfig::new(name.to_owned(), schema, ChannelMode::Durable)
1184 } else {
1185 ChannelConfig::new(name.to_owned(), schema, ChannelMode::Ephemeral)
1186 };
1187 let handle = if durable {
1188 ChannelHandle::new_durable_with_supervisor(
1189 channel_config,
1190 Arc::clone(durable_store),
1191 supervisor.clone(),
1192 )
1193 .map_err(|error| ChannelBuildError::DurableInitFailed {
1194 message: error.to_string(),
1195 })?
1196 } else {
1197 ChannelHandle::with_supervisor(channel_config, supervisor.clone())
1198 };
1199 Ok(ConfiguredChannel {
1200 handle,
1201 protocol_schema: resolved.protocol_id,
1202 origin,
1203 state: AtomicU8::new(STATE_ACTIVE),
1204 quiesce_reason: OnceLock::new(),
1205 })
1206}
1207
1208/// Returns the current epoch-millis timestamp used as the dedup entry anchor.
1209///
1210/// A clock error before the Unix epoch yields `0`: the timestamp is only a TTL
1211/// anchor for the in-memory cache and a zero anchor never breaks the at-most-once
1212/// claim semantics, so this avoids surfacing a clock fault on the publish path.
1213fn dedup_timestamp_millis() -> u64 {
1214 use std::time::{SystemTime, UNIX_EPOCH};
1215 SystemTime::now()
1216 .duration_since(UNIX_EPOCH)
1217 .ok()
1218 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1219 .unwrap_or(0)
1220}
1221
1222/// Default shard count for an on-disk durable store.
1223///
1224/// Haematite routes keys across this many single-threaded shard actors; a small
1225/// power of two gives parallelism across cursors/streams without spawning an
1226/// actor per core. The value is fixed (haematite has no silent default) and not
1227/// yet surfaced in server config.
1228const DEFAULT_SHARD_COUNT: usize = 8;
1229
1230/// Namespace prefix for the dedup-on-delivery cache streams. Keeps delivery dedup
1231/// keys from colliding with any other haematite streams in the shared store.
1232const DELIVERY_DEDUP_NAMESPACE: &str = "liminal:delivery-dedup";
1233
1234/// Beamr-scheduler-owning subsystems the full-service construction path builds
1235/// beyond the connection supervisor's own scheduler. The §9 D2 seam census counts
1236/// these: the worker-front-door profile must construct NONE of them. Test-gated:
1237/// this is the recording vocabulary of the gate's instrument, not production state.
1238#[cfg(test)]
1239#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1240pub(super) enum SchedulerSubsystem {
1241 /// The shared channel supervisor (its own beamr scheduler).
1242 ChannelSupervisor,
1243 /// The conversation supervisor (its own beamr scheduler).
1244 ConversationSupervisor,
1245 /// The haematite store's database (its shard-actor scheduler).
1246 HaematiteStore,
1247}
1248
1249/// Constructor seam for every scheduler-owning subsystem (`SchedulerSubsystem`)
1250/// the profile-aware construction path can create.
1251///
1252/// These methods are the ONLY route through which [`build_connection_services`]
1253/// and the [`LiminalConnectionServices`] config constructors reach
1254/// `build_channel_cluster`, `ConversationSupervisor::new`, and the durable-store
1255/// constructors — no direct constructor call exists in those bodies. The §9 D2
1256/// gate therefore injects a factory that records as a side effect of
1257/// constructing (the D3 store-seam ownership move applied to schedulers): a
1258/// recording cannot be omitted without also failing to construct the subsystem,
1259/// which closes the "hand-placed census call beside the constructor" gap where a
1260/// future subsystem could be built without its courtesy call.
1261pub(super) trait SubsystemFactory {
1262 /// Constructs the shared channel supervisor + cluster resolver (a beamr
1263 /// scheduler).
1264 ///
1265 /// # Errors
1266 /// Returns [`ServerError`] when the channel supervisor scheduler cannot start.
1267 fn channel_cluster(
1268 &self,
1269 cluster_config: Option<&ClusterConfig>,
1270 ) -> Result<ChannelCluster, ServerError>;
1271
1272 /// Constructs the conversation supervisor (a beamr scheduler).
1273 ///
1274 /// # Errors
1275 /// Returns [`ServerError`] when the conversation supervisor scheduler cannot
1276 /// start.
1277 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError>;
1278
1279 /// Constructs the durable store (haematite's shard-actor scheduler):
1280 /// persistent under `persistence_path`, self-owning ephemeral otherwise.
1281 ///
1282 /// # Errors
1283 /// Returns [`ServerError`] when the store cannot be opened.
1284 fn durable_store(
1285 &self,
1286 persistence_path: Option<&Path>,
1287 ) -> Result<Arc<dyn DurableStore>, ServerError>;
1288}
1289
1290/// The production factory: the real constructors, recording nothing.
1291pub(super) struct ProductionSubsystems;
1292
1293impl SubsystemFactory for ProductionSubsystems {
1294 fn channel_cluster(
1295 &self,
1296 cluster_config: Option<&ClusterConfig>,
1297 ) -> Result<ChannelCluster, ServerError> {
1298 build_channel_cluster(cluster_config)
1299 }
1300
1301 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
1302 Ok(Arc::new(ConversationSupervisor::new().map_err(
1303 |error| ServerError::ConfigValidation {
1304 message: format!("failed to start conversation supervisor: {error}"),
1305 },
1306 )?))
1307 }
1308
1309 fn durable_store(
1310 &self,
1311 persistence_path: Option<&Path>,
1312 ) -> Result<Arc<dyn DurableStore>, ServerError> {
1313 build_durable_store(persistence_path)
1314 }
1315}
1316
1317/// Test-only recording implementation of [`SubsystemFactory`] for the §9 D2
1318/// construction gate. Lives here (not in a test module's private scope) so the
1319/// supervisor-level gate test reuses the same instrument.
1320#[cfg(test)]
1321#[allow(clippy::expect_used)]
1322pub(super) mod subsystem_census {
1323 use std::path::{Path, PathBuf};
1324 use std::sync::{Arc, Mutex};
1325
1326 use liminal::conversation::ConversationSupervisor;
1327 use liminal::durability::{DurableStore, open_ephemeral_rooted};
1328
1329 use super::{
1330 ChannelCluster, DEFAULT_SHARD_COUNT, ProductionSubsystems, SchedulerSubsystem,
1331 SubsystemFactory, build_durable_store_with,
1332 };
1333 use crate::ServerError;
1334 use crate::config::types::ClusterConfig;
1335
1336 /// Records each [`SchedulerSubsystem`] AS A SIDE EFFECT of constructing it,
1337 /// then hands back the production-constructed subsystem (with ephemeral
1338 /// stores rooted in an isolated directory, so the fs half of the gate is a
1339 /// real negative assertion). Because [`SubsystemFactory`] is the only route
1340 /// the profile-aware construction path has to these constructors, a
1341 /// recording cannot be omitted without also failing to construct — the
1342 /// record-by-construction guarantee.
1343 pub struct RecordingSubsystems {
1344 census: Mutex<Vec<SchedulerSubsystem>>,
1345 ephemeral_root: PathBuf,
1346 }
1347
1348 impl RecordingSubsystems {
1349 /// A recording factory whose ephemeral stores live under `ephemeral_root`.
1350 pub fn rooted(ephemeral_root: &Path) -> Self {
1351 Self {
1352 census: Mutex::new(Vec::new()),
1353 ephemeral_root: ephemeral_root.to_path_buf(),
1354 }
1355 }
1356
1357 /// The recorded construction census, sorted for order-independent
1358 /// comparison.
1359 pub fn recorded(&self) -> Vec<SchedulerSubsystem> {
1360 let mut recorded = self
1361 .census
1362 .lock()
1363 .expect("subsystem census lock is never poisoned in tests")
1364 .clone();
1365 recorded.sort();
1366 recorded
1367 }
1368
1369 fn record(&self, subsystem: SchedulerSubsystem) {
1370 self.census
1371 .lock()
1372 .expect("subsystem census lock is never poisoned in tests")
1373 .push(subsystem);
1374 }
1375 }
1376
1377 impl SubsystemFactory for RecordingSubsystems {
1378 fn channel_cluster(
1379 &self,
1380 cluster_config: Option<&ClusterConfig>,
1381 ) -> Result<ChannelCluster, ServerError> {
1382 let cluster = ProductionSubsystems.channel_cluster(cluster_config)?;
1383 self.record(SchedulerSubsystem::ChannelSupervisor);
1384 Ok(cluster)
1385 }
1386
1387 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
1388 let supervisor = ProductionSubsystems.conversation_supervisor()?;
1389 self.record(SchedulerSubsystem::ConversationSupervisor);
1390 Ok(supervisor)
1391 }
1392
1393 fn durable_store(
1394 &self,
1395 persistence_path: Option<&Path>,
1396 ) -> Result<Arc<dyn DurableStore>, ServerError> {
1397 let store = build_durable_store_with(persistence_path, || {
1398 open_ephemeral_rooted(&self.ephemeral_root, DEFAULT_SHARD_COUNT)
1399 })?;
1400 self.record(SchedulerSubsystem::HaematiteStore);
1401 Ok(store)
1402 }
1403 }
1404}
1405
1406/// Full-only constructor guard: rejects a config whose profile is not `Full`.
1407///
1408/// [`LiminalConnectionServices`]' config-based constructors call this at entry so
1409/// the full service stack can never be built for a worker-front-door config —
1410/// profile enforcement holds on every public construction path, not only the
1411/// file-loading pipeline.
1412fn require_full_profile(config: &ServerConfig) -> Result<(), ServerError> {
1413 match config.services.profile()? {
1414 ServiceProfile::Full => Ok(()),
1415 ServiceProfile::WorkerFrontDoor => Err(ServerError::ConfigValidation {
1416 message: format!(
1417 "services.profile: \"{}\" cannot construct the full LiminalConnectionServices; \
1418 build profile-selected services via build_connection_services",
1419 ServiceProfile::WORKER_FRONT_DOOR
1420 ),
1421 }),
1422 }
1423}
1424
1425/// Builds the connection-services adapter selected by `config`'s service profile.
1426///
1427/// `Full` builds [`LiminalConnectionServices`] (channels, conversations, durable
1428/// store, dedup cache) exactly as today. `WorkerFrontDoor` builds
1429/// [`WorkerFrontDoorServices`], which constructs none of that machinery. This is the
1430/// single profile-dispatch authority: [`super::supervisor::ConnectionSupervisor`]'s
1431/// config constructor and the standalone runtime's worker arm both route through it
1432/// (the runtime's full arm stays on the explicit
1433/// [`LiminalConnectionServices::from_config`] path because it also needs the shared
1434/// channel cluster, which the trait object does not expose — that path is guarded
1435/// full-only at entry).
1436///
1437/// # Errors
1438/// Returns [`ServerError`] when the selected adapter cannot be constructed, the
1439/// configured profile value is not recognised, or the worker-front-door profile is
1440/// combined with full-only config fields.
1441pub fn build_connection_services(
1442 config: &ServerConfig,
1443) -> Result<Arc<dyn ConnectionServices>, ServerError> {
1444 build_connection_services_via(config, &ProductionSubsystems)
1445}
1446
1447/// [`build_connection_services`] with the subsystem factory injected — the §9 D2
1448/// gate seam, both halves at once.
1449///
1450/// Every scheduler-owning subsystem the `Full` branch creates is constructed
1451/// through `subsystems` and nowhere else, so a recording factory observes exactly
1452/// what was built (thread half, record-by-construction); the gate's factory also
1453/// roots its ephemeral stores in an isolated directory, so "the root stays empty
1454/// on the worker branch" is a real negative assertion (fs half, the D3 pattern).
1455/// The `WorkerFrontDoor` branch never touches the factory — a regression that gave
1456/// the front door any subsystem would both record in the census and land a store
1457/// directory in the injected root.
1458///
1459/// The worker branch re-runs the cross-field checks here, not only in file-loading
1460/// validation, so a directly-constructed config cannot smuggle full-only machinery
1461/// past the profile.
1462pub(super) fn build_connection_services_via(
1463 config: &ServerConfig,
1464 subsystems: &dyn SubsystemFactory,
1465) -> Result<Arc<dyn ConnectionServices>, ServerError> {
1466 match config.services.profile()? {
1467 ServiceProfile::Full => {
1468 let store = subsystems.durable_store(config.persistence_path.as_deref())?;
1469 Ok(Arc::new(
1470 LiminalConnectionServices::from_config_with_store_via(config, store, subsystems)?,
1471 ))
1472 }
1473 ServiceProfile::WorkerFrontDoor => {
1474 let errors = crate::config::validation::worker_front_door_field_errors(config);
1475 if !errors.is_empty() {
1476 return Err(ServerError::ConfigValidation {
1477 message: errors.join("; "),
1478 });
1479 }
1480 Ok(Arc::new(WorkerFrontDoorServices::new()))
1481 }
1482 }
1483}
1484
1485/// Builds the haematite-backed durable store.
1486///
1487/// When `persistence_path` is `Some`, the database lives there and survives
1488/// process restarts: an existing database directory is reopened, a fresh one is
1489/// created. When it is `None` (no durable path configured, or the channel-free
1490/// `empty()` services used by tests), a self-owning ephemeral store is opened
1491/// instead: its temporary directory is created and removed by the store itself
1492/// (D3), so it leaves no residue once the last store handle drops. The two paths
1493/// return distinct concrete stores on purpose — only the ephemeral one carries a
1494/// directory guard; the persistent path is untouched.
1495fn build_durable_store(
1496 persistence_path: Option<&Path>,
1497) -> Result<Arc<dyn DurableStore>, ServerError> {
1498 build_durable_store_with(persistence_path, || open_ephemeral(DEFAULT_SHARD_COUNT))
1499}
1500
1501/// [`build_durable_store`] with the ephemeral factory injected.
1502///
1503/// The split exists for the D3 construction gates: the SAME branch logic runs
1504/// in production and tests, and only the factory closure differs — tests root
1505/// the ephemeral store in an isolated directory (via liminal's test-gated
1506/// rooted factory) so "no ephemeral directory was created" is a real assertion
1507/// rather than a scan of the shared system temp dir.
1508fn build_durable_store_with(
1509 persistence_path: Option<&Path>,
1510 make_ephemeral: impl FnOnce() -> Result<EphemeralHaematiteStore, DurabilityError>,
1511) -> Result<Arc<dyn DurableStore>, ServerError> {
1512 let Some(path) = persistence_path else {
1513 let store = make_ephemeral().map_err(|error| ServerError::ConfigValidation {
1514 message: format!("failed to open ephemeral durable store: {error}"),
1515 })?;
1516 return Ok(Arc::new(store));
1517 };
1518 let data_dir = path.join("durability");
1519 let database = open_or_create_database(&data_dir)?;
1520 let event_store = EventStore::new(database);
1521 Ok(Arc::new(HaematiteStore::new(Arc::new(event_store))))
1522}
1523
1524/// Opens an existing haematite database at `data_dir`, or creates one.
1525fn open_or_create_database(data_dir: &Path) -> Result<Database, ServerError> {
1526 let config_file = data_dir.join("config.json");
1527 let result = if config_file.exists() {
1528 Database::open(data_dir)
1529 } else {
1530 Database::create(DatabaseConfig {
1531 data_dir: data_dir.to_path_buf(),
1532 shard_count: DEFAULT_SHARD_COUNT,
1533 distributed: None,
1534 executor_threads: None,
1535 // haematite 0.8.3 requires this field and refuses `None` at
1536 // validation; `Unlimited` is the crate's explicit spelling of the
1537 // pre-budget (0.8.1) behaviour — the behaviour-preserving choice
1538 // for a dependency hop. A real byte ceiling is a deployment
1539 // decision for its own measured lane. ⚠ The `Database::open` arm
1540 // above is unaffected on CREATE but a store whose `config.json`
1541 // predates this field refuses to open (`MissingNodeCacheBudget`)
1542 // until an operator writes a budget into that file — loud by
1543 // haematite's design, named in this hop's lane report.
1544 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1545 })
1546 };
1547 result.map_err(|error| ServerError::ConfigValidation {
1548 message: format!(
1549 "failed to open durable store at {}: {error}",
1550 data_dir.display()
1551 ),
1552 })
1553}
1554
1555impl ConnectionServices for LiminalConnectionServices {
1556 fn participant_service(&self) -> Option<InstalledParticipantService> {
1557 self.participant_service.clone()
1558 }
1559
1560 /// The roster's admission decision, exposed across the trait boundary as the
1561 /// permission alone.
1562 ///
1563 /// Two methods share the name `admit_channel` on this type: this trait
1564 /// method, and the private inherent funnel it delegates to. They are the
1565 /// same decision at two different boundaries — the funnel returns the ENTRY
1566 /// the decision was made on, because its in-crate callers go on to publish
1567 /// or subscribe through it, and this one returns `()`, because a caller
1568 /// outside the crate is asking whether it MAY, not for the thing itself.
1569 /// Handing the entry out here would make [`ConfiguredChannel`] public
1570 /// surface and hand a frame-level caller a channel handle it has no business
1571 /// holding. Rust resolves an inherent method ahead of a trait method of the
1572 /// same name, so the funnel's existing callers — and the `Self::` call below
1573 /// — reach the funnel, not this method; the trait form is reached only
1574 /// through a `dyn ConnectionServices`, which is exactly the caller it exists
1575 /// for. `an_admitted_operation_the_service_then_refuses_stays_undifferentiated`
1576 /// is the instrument that would catch this delegation turning into a
1577 /// recursion.
1578 ///
1579 /// `operation` is unused in v1 and the parameter is still right: absence
1580 /// refuses both operations, and quiesce refuses new publishes and new
1581 /// subscribes alike, so the two answers are equal today and not equal by
1582 /// definition. A signature that could not see what it was deciding about
1583 /// would have to break the day they part.
1584 fn admit_channel(
1585 &self,
1586 _operation: ChannelOperation,
1587 channel: &str,
1588 ) -> Result<(), ChannelAccessError> {
1589 Self::admit_channel(self, channel)?;
1590 Ok(())
1591 }
1592
1593 fn publish(
1594 &self,
1595 channel: &str,
1596 envelope: &MessageEnvelope,
1597 idempotency_key: Option<&str>,
1598 ) -> Result<PublishOutcome, ServerError> {
1599 // The roster read holds its guard only long enough to clone the entry's
1600 // `Arc` out; everything below — the dedup bridge and the publish into the
1601 // channel actor — runs with no roster lock held.
1602 //
1603 // This inner admission STAYS even once the connection process consults
1604 // the roster ahead of delegating: this method is public and callable
1605 // without going through a frame at all, and a guard that only exists in
1606 // the caller is not a guard.
1607 let configured = self
1608 .admit_channel(channel)
1609 .map_err(|error| access_to_server_error(&error))?;
1610
1611 // Dedup-on-delivery: a publish carrying an idempotency key is delivered to
1612 // subscribers AT MOST ONCE across re-publishes of the same key. Only a
1613 // fresh `Claimed` decision proceeds to fan-out; a `Completed`/`InFlight`
1614 // decision is a duplicate and is suppressed (no second delivery), which is
1615 // the at-most-once guarantee the aion outbox relies on.
1616 if let Some(key) = idempotency_key {
1617 if !self.claim_delivery(key)? {
1618 // A dedup-suppressed re-publish is still an accepted publish (it is
1619 // assigned a message id), but it reaches no subscriber, so it counts
1620 // toward publishes and not deliveries.
1621 crate::metrics::publish_accepted();
1622 return Ok(PublishOutcome {
1623 message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
1624 delivered: false,
1625 });
1626 }
1627 }
1628
1629 let delivery = configured.handle.publish_with_delivery(
1630 &envelope.payload,
1631 liminal::envelope::PublisherId::default(),
1632 None,
1633 );
1634 let delivery = match delivery {
1635 Ok(delivery) => delivery,
1636 Err(error) => {
1637 // The claim above appended an `InFlight` entry but the delivery
1638 // failed before `complete_receipt` could run. Release the claim so
1639 // the key is re-claimable; otherwise every re-publish would see
1640 // `InFlight` and be suppressed forever. Best-effort: surface the
1641 // ORIGINAL publish error regardless, but never swallow a release
1642 // failure silently (it leaves the leak intact).
1643 if let Some(key) = idempotency_key {
1644 self.release_claim(key);
1645 }
1646 return Err(ServerError::ListenerAccept {
1647 message: format!("liminal publish failed for channel '{channel}': {error}"),
1648 });
1649 }
1650 };
1651
1652 // Record the dedup completion AFTER a successful claimed delivery so the
1653 // claim is not left dangling `InFlight` (which would wrongly defer every
1654 // future duplicate). The receipt body is empty: the dedup contract here
1655 // only needs presence, not a stored result.
1656 if let Some(key) = idempotency_key {
1657 block_on(
1658 self.dedup
1659 .complete_receipt(key, ProcessingReceipt::new(Vec::new())),
1660 )
1661 .map_err(|error| ServerError::ListenerAccept {
1662 message: format!("dedup receipt bridge failed for key '{key}': {error}"),
1663 })?
1664 .map_err(|error| ServerError::ListenerAccept {
1665 message: format!("dedup receipt write failed for key '{key}': {error}"),
1666 })?;
1667 }
1668
1669 // Record the accepted publish and its genuine subscriber deliveries. The
1670 // delivered count (0 for an empty channel) is the same signal the delivery
1671 // ack is derived from.
1672 crate::metrics::publish_accepted();
1673 let delivered_count = u64::try_from(delivery.delivered_count()).unwrap_or(u64::MAX);
1674 crate::metrics::deliveries_recorded(delivered_count);
1675
1676 Ok(PublishOutcome {
1677 message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
1678 delivered: delivery.is_delivered(),
1679 })
1680 }
1681
1682 fn subscribe(
1683 &self,
1684 channel: &str,
1685 accepted_schemas: &[ProtocolSchemaId],
1686 install: Option<liminal::channel::InboxInstall>,
1687 ) -> Result<ConnectionSubscription, ServerError> {
1688 // As in `publish`: the admission funnel clones the entry's `Arc` out
1689 // under the read guard and the guard is released before schema
1690 // negotiation or the actor round-trip below. That release is what makes
1691 // this the linearisation point — a quiesce landing after it does not
1692 // stop the subscription this call is already building.
1693 let configured = self
1694 .admit_channel(channel)
1695 .map_err(|error| access_to_server_error(&error))?;
1696 let selected_schema = if accepted_schemas.is_empty() {
1697 configured.protocol_schema
1698 } else {
1699 liminal::protocol::negotiate_schema(configured.protocol_schema, accepted_schemas)
1700 .map_err(|error| server_error_from_protocol(&error))?
1701 };
1702 // `subscribe_with_install` installs the §5 budget/fairness cap and the R3
1703 // wake notifier on the inbox at construction — strictly before the
1704 // registration is published to the channel actor — so there is no window
1705 // in which a publish can land uncharged or without a wake.
1706 let subscription = install
1707 .map_or_else(
1708 || configured.handle.subscribe(),
1709 |install| configured.handle.subscribe_with_install(install),
1710 )
1711 .map_err(|error| ServerError::ListenerAccept {
1712 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
1713 })?;
1714 let id = self.next_subscription_id.fetch_add(1, Ordering::Relaxed);
1715 let mut connection_subscription = ConnectionSubscription::new(
1716 id,
1717 selected_schema,
1718 Box::new(LiminalSubscriptionResource { subscription }),
1719 );
1720 connection_subscription.set_channel(channel.to_owned());
1721 Ok(connection_subscription)
1722 }
1723
1724 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError> {
1725 subscription.unsubscribe()
1726 }
1727
1728 fn open_conversation(
1729 &self,
1730 conversation_id: u64,
1731 subject: &str,
1732 ) -> Result<ConnectionConversation, ServerError> {
1733 // Spawn a REAL participant process (a beamr `NativeHandler` running the
1734 // resolved responder behaviour) on the conversation supervisor's
1735 // scheduler, and a supervised conversation actor linked to it. The actor
1736 // FORWARDS each conversation message to the participant, which genuinely
1737 // processes it and delivers a reply back. The actor traps the
1738 // participant's EXIT (a beamr process link), so killing it fires a
1739 // structural, microsecond-scale crash signal.
1740 //
1741 // The responder is chosen by `subject`: a custom responder registered via
1742 // `register_responder` for this subject, or the built-in `EchoBehaviour`
1743 // when none is registered. Either way it runs as the SAME supervised,
1744 // linked participant process — the seam changes WHO responds, not HOW the
1745 // participant is spawned or supervised.
1746 let behaviour = self.responder_for(subject)?;
1747 let (actor, participant) = self
1748 .conversation_supervisor
1749 .spawn_with_participant(behaviour, None, ChannelMode::Ephemeral, CrashPolicy::Fail)
1750 .map_err(|error| ServerError::ListenerAccept {
1751 message: format!(
1752 "failed to spawn supervised conversation {conversation_id} ('{subject}'): {error}"
1753 ),
1754 })?;
1755
1756 // Drive boot to completion so the beamr link to the participant exists
1757 // before any message is forwarded (link-before-forward), mirroring the
1758 // ROUTING-004 dispatch pattern.
1759 actor.pid().map_err(|error| ServerError::ListenerAccept {
1760 message: format!(
1761 "failed to boot supervised conversation {conversation_id} ('{subject}'): {error}"
1762 ),
1763 })?;
1764
1765 // Register the structural EXIT notifier BEFORE returning, so a crash that
1766 // fires the instant a message reaches the participant is never missed.
1767 // The notifier is woken by the actor's trapped-EXIT handler (event
1768 // driven), and a crash that already landed is replayed immediately.
1769 let (exit_tx, exit_rx) = mpsc::sync_channel::<Instant>(1);
1770 actor
1771 .notify_on_participant_exit(participant, exit_tx)
1772 .map_err(|error| ServerError::ListenerAccept {
1773 message: format!(
1774 "failed to arm crash detection for conversation {conversation_id}: {error}"
1775 ),
1776 })?;
1777
1778 Ok(ConnectionConversation::new(Box::new(
1779 LiminalConversationResource::new(actor, participant, exit_rx),
1780 )))
1781 }
1782
1783 fn conversation_message(
1784 &self,
1785 conversation: &ConnectionConversation,
1786 envelope: &MessageEnvelope,
1787 op_id: Option<u64>,
1788 ) -> Result<(), ServerError> {
1789 conversation.message(envelope, op_id)
1790 }
1791
1792 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError> {
1793 conversation.close()
1794 }
1795
1796 fn flush_durable_state(&self) -> Result<(), ServerError> {
1797 // Clone the roster out under the read guard and drop the guard before
1798 // flushing: a shutdown flush is a durable write per entry, and it must
1799 // never run with the roster lock held.
1800 let entries: Vec<(String, Arc<ConfiguredChannel>)> = {
1801 let channels = self.read_channels()?;
1802 channels
1803 .iter()
1804 .map(|(channel_name, configured)| (channel_name.clone(), Arc::clone(configured)))
1805 .collect()
1806 };
1807 for (channel_name, configured) in entries {
1808 if configured.handle.config().mode == ChannelMode::Durable {
1809 configured
1810 .handle
1811 .flush()
1812 .map_err(|error| ServerError::ShutdownFlush {
1813 message: format!(
1814 "failed to flush durable channel '{channel_name}': {error}"
1815 ),
1816 })?;
1817 }
1818 }
1819 Ok(())
1820 }
1821}
1822
1823/// One roster entry: a channel's library handle, the protocol schema id
1824/// advertised for it, where it came from, and its admission state.
1825#[derive(Debug)]
1826pub(super) struct ConfiguredChannel {
1827 handle: ChannelHandle,
1828 protocol_schema: ProtocolSchemaId,
1829 /// Boot-configured or runtime-registered. Written once at construction and
1830 /// NEVER flipped: it is the only field that predicts what a restart does to
1831 /// this entry, and it defines the population the registration cap counts.
1832 origin: ChannelOrigin,
1833 /// [`STATE_ACTIVE`] or [`STATE_QUIESCED`]. Written at most once, by one
1834 /// `compare_exchange`, so the transition is one-way and exactly one caller
1835 /// can perform it.
1836 state: AtomicU8,
1837 /// The quiesce cause, set STRICTLY BEFORE `state` flips so any reader that
1838 /// observes [`STATE_QUIESCED`] can read it. Written once (the `OnceLock`
1839 /// enforces that structurally), which is why a re-quiesce under a different
1840 /// reason must refuse rather than silently keep or replace one of them.
1841 quiesce_reason: OnceLock<String>,
1842}
1843
1844impl ConfiguredChannel {
1845 /// The entry's admission state as a value.
1846 ///
1847 /// One `Acquire` load and, when quiesced, one read of the already-written
1848 /// reason. Touches no actor: the whole point of recording state on the entry
1849 /// is that observing it cannot spawn the thing being observed.
1850 fn state(&self) -> ChannelState {
1851 if self.state.load(Ordering::Acquire) == STATE_QUIESCED {
1852 ChannelState::Quiesced {
1853 reason: self.recorded_quiesce_reason(),
1854 }
1855 } else {
1856 ChannelState::Active
1857 }
1858 }
1859
1860 /// The recorded quiesce reason, for a caller that has already observed
1861 /// [`STATE_QUIESCED`] with an `Acquire` load.
1862 fn recorded_quiesce_reason(&self) -> String {
1863 self.quiesce_reason
1864 .get()
1865 .map_or_else(|| UNRECORDED_QUIESCE_REASON.to_owned(), Clone::clone)
1866 }
1867
1868 /// Moves this entry from active to quiesced, recording `reason` first.
1869 ///
1870 /// The reason is written to the `OnceLock` BEFORE the `Release`
1871 /// `compare_exchange` that flips the state, so no reader can observe
1872 /// `QUIESCED` without being able to read why. Re-quiescing under the
1873 /// IDENTICAL reason is `Ok(())` — the caller's intent already holds — and a
1874 /// DIFFERENT reason refuses, because the recorded one cannot be replaced
1875 /// without losing a cause and cannot be kept without lying to the caller.
1876 fn quiesce(&self, name: &str, reason: &str) -> Result<(), ChannelRegistryError> {
1877 // Whoever wins the `OnceLock` writes the cause on record; every other
1878 // caller — a re-quiesce or a concurrent racer — is judged against THAT
1879 // reason, never against whether it happened to perform the flip itself.
1880 // Reporting success for having won the CAS would tell a racer whose
1881 // reason lost that its reason took effect.
1882 let recorded = match self.quiesce_reason.set(reason.to_owned()) {
1883 Ok(()) => reason.to_owned(),
1884 Err(_rejected) => self.recorded_quiesce_reason(),
1885 };
1886 // The reason is now readable, so the flip may become visible. The CAS
1887 // result is deliberately unused: the state is `QUIESCED` afterwards
1888 // whether this call or a racer performed the transition, and the answer
1889 // to the caller is governed by the recorded reason above.
1890 let _flipped_here = self.state.compare_exchange(
1891 STATE_ACTIVE,
1892 STATE_QUIESCED,
1893 Ordering::Release,
1894 Ordering::Acquire,
1895 );
1896 if recorded == reason {
1897 return Ok(());
1898 }
1899 Err(ChannelRegistryError::AlreadyQuiesced {
1900 name: name.to_owned(),
1901 reason: recorded,
1902 })
1903 }
1904}
1905
1906#[derive(Debug)]
1907struct LiminalSubscriptionResource {
1908 subscription: liminal::channel::SubscriptionHandle,
1909}
1910
1911impl SubscriptionResource for LiminalSubscriptionResource {
1912 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError> {
1913 drop(self.subscription);
1914 Ok(())
1915 }
1916
1917 fn is_overflowed(&self) -> bool {
1918 self.subscription.is_overflowed()
1919 }
1920
1921 fn has_pending(&self) -> bool {
1922 self.subscription.has_pending()
1923 }
1924
1925 fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
1926 match self.subscription.try_next() {
1927 Ok(envelope) => envelope,
1928 Err(error) => {
1929 // A poisoned inbox lock is PERMANENT, not transient: once poisoned it
1930 // stays poisoned, so every future `try_next` also returns `Err` and this
1931 // subscription goes silent for the rest of its life — no further
1932 // deliveries, not "held for the next slice". Poisoning requires a panic
1933 // while the lock is held, which the workspace lints forbid
1934 // (no unwrap/expect/panic), so this is an accepted low-probability
1935 // failure rather than a recoverable one. We keep the connection alive (a
1936 // single permanently-silent subscription is less harmful than tearing
1937 // down every other subscription and stream the connection multiplexes)
1938 // but log loudly so the silence is diagnosable. The log cannot storm:
1939 // it can only fire after the one panic that poisoned the lock.
1940 tracing::error!(
1941 %error,
1942 "subscription inbox lock is poisoned; this subscription is now \
1943 permanently silent and will deliver no further messages"
1944 );
1945 None
1946 }
1947 }
1948 }
1949}
1950
1951/// Renders an admission refusal as the service error the trait's callers expect.
1952///
1953/// The two refusals that already existed keep their EXACT bytes: an absent
1954/// channel is still `channel '<name>' is not configured` and a poisoned roster
1955/// is still `channel roster lock poisoned`, so nothing that reads these messages
1956/// today sees a change. Discrimination lives in the reason code
1957/// ([`ChannelAccessError::reason_code`]), which the connection process carries to
1958/// the wire — this rendering is the degraded path for a caller that reaches the
1959/// service directly and has no code to carry.
1960///
1961/// `Quiesced` has no predecessor string to preserve; it carries the refusal's own
1962/// message, reason included.
1963///
1964/// The connection process renders its admission refusals through this SAME
1965/// function rather than through [`ChannelAccessError`]'s own `Display`. Two
1966/// consequences, both wanted. The bytes on the wire for an absent channel stay
1967/// exactly what they have always been, which is the whole of the semver promise
1968/// this lane makes. And an operation refused AT admission and one refused after
1969/// admission by the service produce the identical message, differing only in the
1970/// reason code — so the code really is the only discriminator, instead of the
1971/// message quietly becoming a second one.
1972pub(super) fn access_to_server_error(error: &ChannelAccessError) -> ServerError {
1973 let message = match error {
1974 ChannelAccessError::NotRegistered { name } => {
1975 format!("channel '{name}' is not configured")
1976 }
1977 ChannelAccessError::Quiesced { .. } => error.to_string(),
1978 ChannelAccessError::RosterUnavailable { message } => message.clone(),
1979 };
1980 ServerError::ListenerAccept { message }
1981}
1982
1983pub(super) fn server_error_from_protocol(error: &ProtocolError) -> ServerError {
1984 ServerError::ListenerAccept {
1985 message: format!("protocol operation failed: {error}"),
1986 }
1987}
1988
1989/// The three pinned registration tests. A CHILD module of `services` because
1990/// test 2 must hold the exact entry the admission funnel handed out.
1991#[cfg(test)]
1992#[path = "services_registry_tests.rs"]
1993mod services_registry_tests;
1994
1995#[cfg(test)]
1996#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1997mod durable_store_tests {
1998 use liminal::durability::open_ephemeral_rooted;
1999
2000 use super::subsystem_census::RecordingSubsystems;
2001 use super::{
2002 ConnectionServices, DEFAULT_SHARD_COUNT, LiminalConnectionServices, SchedulerSubsystem,
2003 build_connection_services, build_connection_services_via, build_durable_store_with,
2004 };
2005 use crate::ServerError;
2006 use crate::config::types::{LimitsConfig, ServerConfig, ServicesConfig};
2007
2008 /// Counts directory entries under `root`, for the empty/one-dir assertions
2009 /// on an injected ephemeral root.
2010 fn entry_count(root: &std::path::Path) -> usize {
2011 std::fs::read_dir(root)
2012 .expect("ephemeral root is readable")
2013 .count()
2014 }
2015
2016 /// A minimal channel-free config with the given service `profile`. No channels,
2017 /// routing, persistence, or cluster — the shape both profiles accept (the full
2018 /// profile simply builds an empty channel set; the worker-front-door profile
2019 /// requires exactly this shape).
2020 fn config_with_profile(profile: &str) -> ServerConfig {
2021 ServerConfig {
2022 listen_address: "127.0.0.1:0".parse().expect("valid socket addr"),
2023 health_listen_address: "127.0.0.1:1".parse().expect("valid socket addr"),
2024 drain_timeout_ms: 30_000,
2025 channels: Vec::new(),
2026 routing_rules: Vec::new(),
2027 persistence_path: None,
2028 cluster: None,
2029 auth: None,
2030 services: ServicesConfig {
2031 profile: profile.to_owned(),
2032 },
2033 limits: LimitsConfig::default(),
2034 participant: None,
2035 websocket: None,
2036 }
2037 }
2038
2039 /// §9 D2 front-door construction gate (fs half): building the worker-front-door
2040 /// services creates NO haematite store and NO temp dir, while the full profile
2041 /// over an equally-rooted factory DOES create exactly one store directory.
2042 ///
2043 /// The injected root is the only place an ephemeral store directory can appear
2044 /// (the recording factory roots its stores there), so "the root stays empty on
2045 /// the front-door branch" is a real negative assertion: a regression that gave
2046 /// the front door a store would land its directory here and fail this test. The
2047 /// full-profile arm is the positive control proving the seam genuinely
2048 /// constructs a store when the profile asks for one.
2049 #[test]
2050 fn worker_front_door_builds_no_store_and_no_temp_dir() {
2051 let front_door_root = tempfile::tempdir().expect("test can create an ephemeral root");
2052 let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
2053
2054 let front_door_subsystems = RecordingSubsystems::rooted(front_door_root.path());
2055 let front_door: std::sync::Arc<dyn ConnectionServices> = build_connection_services_via(
2056 &config_with_profile("worker-front-door"),
2057 &front_door_subsystems,
2058 )
2059 .expect("worker-front-door services build");
2060 assert!(
2061 !front_door.supports_channel_operations(),
2062 "the worker front door serves no channel operations"
2063 );
2064 assert_eq!(
2065 entry_count(front_door_root.path()),
2066 0,
2067 "the worker front door creates no ephemeral store directory (no haematite, no temp dir)"
2068 );
2069
2070 let full_subsystems = RecordingSubsystems::rooted(full_root.path());
2071 let full = build_connection_services_via(&config_with_profile("full"), &full_subsystems)
2072 .expect("full services build");
2073 assert!(
2074 full.supports_channel_operations(),
2075 "full mode serves channel operations"
2076 );
2077 assert_eq!(
2078 entry_count(full_root.path()),
2079 1,
2080 "full mode with no persistence path builds exactly one ephemeral store directory"
2081 );
2082
2083 drop(front_door);
2084 drop(full);
2085 }
2086
2087 /// §9 D2 front-door construction gate (thread half — record-by-construction
2088 /// census): the worker profile constructs NO channel-supervisor,
2089 /// conversation-supervisor, or haematite scheduler, while the SAME instrument
2090 /// over the full profile records all three — the positive control proving the
2091 /// census detects the extra schedulers, so an empty census on the worker branch
2092 /// is a real observation, not a decoration.
2093 ///
2094 /// The instrument's boundary: recording happens INSIDE the [`SubsystemFactory`]
2095 /// methods that are the profile-aware path's only route to these constructors,
2096 /// so a recording cannot be silently omitted — a future subsystem added to this
2097 /// path either goes through the factory (and is recorded by construction) or
2098 /// bypasses it, which is a code-review-visible structural violation of the
2099 /// factory seam, not a silently-missing side call. The connection supervisor's
2100 /// own scheduler is the shared baseline of both profiles and is asserted at the
2101 /// supervisor level (`supervisor::tests`); an OS-level thread census upgrades
2102 /// this when the beamr composition lane's scheduler-inventory API (currently on
2103 /// their branch, not yet consumable from liminal) lands.
2104 #[test]
2105 fn worker_profile_census_is_empty_and_full_profile_records_all_schedulers() {
2106 let worker_root = tempfile::tempdir().expect("test can create an ephemeral root");
2107 let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
2108
2109 let worker_subsystems = RecordingSubsystems::rooted(worker_root.path());
2110 let front_door = build_connection_services_via(
2111 &config_with_profile("worker-front-door"),
2112 &worker_subsystems,
2113 )
2114 .expect("worker-front-door services build");
2115 assert_eq!(
2116 worker_subsystems.recorded(),
2117 Vec::<SchedulerSubsystem>::new(),
2118 "the worker front door constructs no scheduler-owning subsystem"
2119 );
2120
2121 let full_subsystems = RecordingSubsystems::rooted(full_root.path());
2122 let full = build_connection_services_via(&config_with_profile("full"), &full_subsystems)
2123 .expect("full services build");
2124 assert_eq!(
2125 full_subsystems.recorded(),
2126 vec![
2127 SchedulerSubsystem::ChannelSupervisor,
2128 SchedulerSubsystem::ConversationSupervisor,
2129 SchedulerSubsystem::HaematiteStore,
2130 ],
2131 "the full profile constructs every scheduler-owning subsystem, once each — \
2132 the positive control proving the census instrument detects them"
2133 );
2134
2135 drop(front_door);
2136 drop(full);
2137 }
2138
2139 /// MAJOR-1 regression: the full-only constructors reject a worker-front-door
2140 /// config with a typed `ConfigValidation` error AT ENTRY — no full service can
2141 /// be created through any public config-based constructor under that profile.
2142 #[test]
2143 fn full_only_constructors_reject_worker_profile() {
2144 let config = config_with_profile("worker-front-door");
2145
2146 let from_config = LiminalConnectionServices::from_config(&config);
2147 assert!(
2148 matches!(from_config, Err(ServerError::ConfigValidation { .. })),
2149 "from_config must reject a worker-front-door profile with ConfigValidation, got {from_config:?}"
2150 );
2151
2152 let root = tempfile::tempdir().expect("test can create an ephemeral root");
2153 let store = open_ephemeral_rooted(root.path(), DEFAULT_SHARD_COUNT)
2154 .expect("test store for the rejection check builds");
2155 let from_config_with_store =
2156 LiminalConnectionServices::from_config_with_store(&config, std::sync::Arc::new(store));
2157 assert!(
2158 matches!(
2159 from_config_with_store,
2160 Err(ServerError::ConfigValidation { .. })
2161 ),
2162 "from_config_with_store must reject a worker-front-door profile with ConfigValidation"
2163 );
2164 }
2165
2166 /// MAJOR-1 regression: the profile-aware factory itself re-runs the
2167 /// worker-front-door cross-field checks, so a directly-constructed config (one
2168 /// that never passed file-loading validation) combining the worker profile with
2169 /// full-only machinery is refused with the same typed `ConfigValidation` errors.
2170 #[test]
2171 fn build_connection_services_rejects_worker_profile_with_full_only_fields() {
2172 let mut config = config_with_profile("worker-front-door");
2173 config.channels = vec![crate::config::types::ChannelDef {
2174 name: "orders".to_owned(),
2175 schema_ref: None,
2176 durable: false,
2177 loaded_schema: None,
2178 }];
2179 config.persistence_path = Some(std::path::PathBuf::from("/tmp"));
2180
2181 let result = build_connection_services(&config);
2182 let Err(ServerError::ConfigValidation { message }) = result else {
2183 panic!("expected ConfigValidation for worker profile with full-only fields");
2184 };
2185 assert!(message.contains("builds no channels"), "got: {message}");
2186 assert!(
2187 message.contains("builds no durable store"),
2188 "got: {message}"
2189 );
2190 }
2191
2192 /// §9 D3 construction gate (persistent half): requesting a *persistent* store
2193 /// creates its database under the configured path and NO ephemeral directory.
2194 ///
2195 /// Exercises `build_durable_store_with` — the same branch logic production
2196 /// runs — with only the ephemeral factory swapped to root in an isolated
2197 /// directory. That root is where any ephemeral directory would have to
2198 /// appear, so "the root stays empty" is a real negative assertion — a
2199 /// regression that constructs an ephemeral store on the persistent branch
2200 /// lands its directory here and fails this test.
2201 #[test]
2202 fn persistent_store_uses_configured_path_and_creates_no_temp_dir() {
2203 let home = tempfile::tempdir().expect("test can create a temp dir");
2204 let ephemeral_root = tempfile::tempdir().expect("test can create an ephemeral root");
2205
2206 let store = build_durable_store_with(Some(home.path()), || {
2207 open_ephemeral_rooted(ephemeral_root.path(), DEFAULT_SHARD_COUNT)
2208 })
2209 .expect("persistent store builds");
2210
2211 assert!(
2212 home.path().join("durability").join("config.json").exists(),
2213 "the persistent database is created under the configured path"
2214 );
2215 assert_eq!(
2216 entry_count(ephemeral_root.path()),
2217 0,
2218 "the persistent branch creates no ephemeral guard directory"
2219 );
2220
2221 drop(store);
2222 }
2223
2224 /// Pins the wiring seam: the ephemeral (`None`) branch of the shared build
2225 /// logic goes through the guarded constructor — exactly one directory
2226 /// appears under the injected root while the store lives, and zero residue
2227 /// remains after the last handle drops.
2228 #[test]
2229 fn ephemeral_store_directory_is_owned_through_the_build_seam() {
2230 let ephemeral_root = tempfile::tempdir().expect("test can create an ephemeral root");
2231
2232 let store = build_durable_store_with(None, || {
2233 open_ephemeral_rooted(ephemeral_root.path(), DEFAULT_SHARD_COUNT)
2234 })
2235 .expect("ephemeral store builds");
2236
2237 assert_eq!(
2238 entry_count(ephemeral_root.path()),
2239 1,
2240 "the ephemeral branch creates exactly one guard directory"
2241 );
2242
2243 drop(store);
2244
2245 assert_eq!(
2246 entry_count(ephemeral_root.path()),
2247 0,
2248 "dropping the last store handle removes the guard directory — zero residue"
2249 );
2250 }
2251}