Skip to main content

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