Skip to main content

liminal_server/server/connection/
services.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, mpsc};
5use std::time::Instant;
6
7use haematite::{Database, DatabaseConfig, EventStore};
8use liminal::channel::{ChannelConfig, ChannelHandle, ChannelMode, 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::conversation::{ConnectionConversation, LiminalConversationResource};
20use super::services_cluster::build_channel_cluster;
21use super::services_schema::resolve_channel_schema;
22use super::worker_front_door::WorkerFrontDoorServices;
23use crate::ServerError;
24use crate::config::types::{ClusterConfig, ServerConfig, ServiceProfile};
25use crate::server::participant::{InstalledParticipantService, ProductionParticipantHandler};
26
27pub use super::services_cluster::ChannelCluster;
28
29/// Registry of custom conversation responders, keyed by conversation subject.
30///
31/// A registered [`ParticipantBehaviour`] becomes the participant for any
32/// conversation opened on its subject; subjects with no entry fall back to the
33/// built-in [`EchoBehaviour`].
34type ResponderRegistry = HashMap<String, Arc<dyn ParticipantBehaviour>>;
35
36/// Marker for resources retained by a connection process until unsubscribe.
37pub trait SubscriptionResource: std::fmt::Debug + Send {
38    /// Releases the library subscription resource.
39    ///
40    /// # Errors
41    /// Returns [`ServerError`] when the liminal library reports an unsubscribe failure.
42    fn unsubscribe(self: Box<Self>) -> Result<(), ServerError>;
43
44    /// Attempts to pull the next delivered envelope from the wrapped library
45    /// subscription without blocking.
46    ///
47    /// Returns `None` when the subscriber inbox is empty (or momentarily
48    /// unavailable): the connection process is the delivery pump, so a transient
49    /// empty read is simply "nothing to deliver this slice", never an error.
50    fn try_next(&mut self) -> Option<liminal::envelope::Envelope>;
51
52    /// Non-consuming availability query for the post-arm race barrier.
53    fn has_pending(&self) -> bool;
54
55    /// Whether an overflow has marked this subscription for shedding (§5). The
56    /// delivery pump sheds an overflowed subscription with a typed error frame.
57    /// Defaulted to `false`: a resource with no bounded inbox never overflows.
58    fn is_overflowed(&self) -> bool {
59        false
60    }
61}
62
63/// Library subscription resource owned by a single connection process.
64#[derive(Debug)]
65pub struct ConnectionSubscription {
66    id: u64,
67    /// Client-chosen application stream the server delivers this subscription's
68    /// messages on (echoed on `SubscribeAck`, carried on every `Deliver`). Set by
69    /// the connection process from the `Subscribe` frame before the subscription is
70    /// stored; `0` only while momentarily unset during construction.
71    stream_id: u32,
72    selected_schema: ProtocolSchemaId,
73    resource: Box<dyn SubscriptionResource>,
74}
75
76impl ConnectionSubscription {
77    /// Creates an owned subscription resource for one connection process.
78    #[must_use]
79    pub fn new(
80        id: u64,
81        selected_schema: ProtocolSchemaId,
82        resource: Box<dyn SubscriptionResource>,
83    ) -> Self {
84        Self {
85            id,
86            stream_id: 0,
87            selected_schema,
88            resource,
89        }
90    }
91
92    /// Returns the protocol subscription id.
93    #[must_use]
94    pub const fn id(&self) -> u64 {
95        self.id
96    }
97
98    /// Records the client-chosen delivery stream id for this subscription.
99    pub(super) const fn set_stream_id(&mut self, stream_id: u32) {
100        self.stream_id = stream_id;
101    }
102
103    /// Returns the client-chosen application stream id deliveries ride on.
104    #[must_use]
105    pub(super) const fn stream_id(&self) -> u32 {
106        self.stream_id
107    }
108
109    /// Returns the schema selected for this subscription stream.
110    #[must_use]
111    pub const fn selected_schema(&self) -> ProtocolSchemaId {
112        self.selected_schema
113    }
114
115    /// Attempts to pull the next delivered envelope without blocking.
116    pub(super) fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
117        self.resource.try_next()
118    }
119
120    pub(super) fn has_pending(&self) -> bool {
121        self.resource.has_pending()
122    }
123
124    /// Whether this subscription has been shed by an inbox overflow (§5).
125    pub(super) fn is_overflowed(&self) -> bool {
126        self.resource.is_overflowed()
127    }
128
129    pub(super) fn unsubscribe(self) -> Result<(), ServerError> {
130        self.resource.unsubscribe()
131    }
132}
133
134/// Outcome of a server publish.
135///
136/// Carries the assigned message id plus a genuine delivery ack (`delivered` = the
137/// message was accepted by at least one live subscriber on this publish, after any
138/// dedup-on-delivery suppression).
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub struct PublishOutcome {
141    /// Monotonic message id assigned to the accepted publish.
142    pub message_id: u64,
143    /// Whether the message was genuinely delivered to a subscriber. `false` means
144    /// the publish was accepted but reached no subscriber (empty channel) or was
145    /// a duplicate suppressed by dedup-on-delivery.
146    pub delivered: bool,
147}
148
149/// Operations that adapt wire frames to liminal library calls.
150pub trait ConnectionServices: std::fmt::Debug + Send + Sync {
151    /// Returns the complete participant service installed on this adapter.
152    ///
153    /// `None` keeps participant capability disabled even when the adapter owns a
154    /// durable store for unrelated channel traffic. The returned token is
155    /// server-sealed and atomically carries declared semantics plus durability,
156    /// making a handler-without-store activation impossible by construction.
157    fn participant_service(&self) -> Option<InstalledParticipantService> {
158        None
159    }
160
161    /// Delegates a publish request to the liminal library.
162    ///
163    /// `idempotency_key`, when `Some`, drives dedup-on-delivery: a re-publish with
164    /// the same key is delivered to subscribers at most once. The returned
165    /// [`PublishOutcome`] carries the genuine delivery ack.
166    ///
167    /// # Errors
168    /// Returns [`ServerError`] when the liminal publish operation fails.
169    fn publish(
170        &self,
171        channel: &str,
172        envelope: &MessageEnvelope,
173        idempotency_key: Option<&str>,
174    ) -> Result<PublishOutcome, ServerError>;
175
176    /// Delegates a subscribe request to the liminal library.
177    ///
178    /// `install`, when `Some`, carries the connection's §5 shared inbox byte
179    /// budget, per-inbox fairness cap, and R3 wake notifier. The implementation
180    /// MUST install it on the subscription's inbox BEFORE the registration is
181    /// published to the channel actor (i.e. before any envelope can be
182    /// delivered), so no envelope is ever admitted uncharged, past the depth
183    /// cap, or without a wake. Implementations with no real inbox (test
184    /// stand-ins, capability-scoped profiles that refuse subscribe) may ignore
185    /// it.
186    ///
187    /// # Errors
188    /// Returns [`ServerError`] when the liminal subscribe operation fails.
189    fn subscribe(
190        &self,
191        channel: &str,
192        accepted_schemas: &[ProtocolSchemaId],
193        install: Option<liminal::channel::InboxInstall>,
194    ) -> Result<ConnectionSubscription, ServerError>;
195
196    /// Delegates unsubscribe to the liminal library.
197    ///
198    /// # Errors
199    /// Returns [`ServerError`] when the liminal unsubscribe operation fails.
200    fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError>;
201
202    /// Delegates conversation open to the liminal library.
203    ///
204    /// # Errors
205    /// Returns [`ServerError`] when the liminal conversation open operation fails.
206    fn open_conversation(
207        &self,
208        conversation_id: u64,
209        subject: &str,
210    ) -> Result<ConnectionConversation, ServerError>;
211
212    /// Delegates a conversation message to the liminal library.
213    ///
214    /// # Errors
215    /// Returns [`ServerError`] when the liminal conversation message operation fails.
216    fn conversation_message(
217        &self,
218        conversation: &ConnectionConversation,
219        envelope: &MessageEnvelope,
220    ) -> Result<(), ServerError>;
221
222    /// Delegates conversation close to the liminal library.
223    ///
224    /// # Errors
225    /// Returns [`ServerError`] when the liminal conversation close operation fails.
226    fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError>;
227
228    /// Flushes durable channel state through the liminal library boundary.
229    ///
230    /// # Errors
231    /// Returns [`ServerError`] when the liminal channel flush operation fails.
232    fn flush_durable_state(&self) -> Result<(), ServerError>;
233
234    /// Whether this adapter backs ordinary channel and conversation operations.
235    ///
236    /// The default is `true` — the full-service adapter serves publish, subscribe,
237    /// and conversation frames, so full mode is byte-for-byte unchanged. The
238    /// capability-scoped worker front door overrides this to `false`, letting
239    /// [`super::apply`] reject the channel/conversation frames it short-circuits on
240    /// empty connection state (`Unsubscribe`, `ConversationMessage`,
241    /// `ConversationClose`) with a typed error frame instead of silently swallowing
242    /// an operation for a resource that could never have been created in this
243    /// profile. Frames that always reach a service method (`Publish`, `Subscribe`,
244    /// `ConversationOpen`) are rejected by the front door's own method bodies and do
245    /// not consult this flag.
246    fn supports_channel_operations(&self) -> bool {
247        true
248    }
249}
250
251/// Default adapter from server wire frames to liminal channel/conversation APIs.
252#[derive(Debug)]
253pub struct LiminalConnectionServices {
254    channels: HashMap<String, ConfiguredChannel>,
255    cluster: ChannelCluster,
256    durable_store: Arc<dyn DurableStore>,
257    /// Complete participant service, installed only when semantic lifecycle
258    /// handling and its durable aggregate store are both ready.
259    participant_service: Option<InstalledParticipantService>,
260    /// In-memory (haematite-backed) dedup cache for dedup-on-delivery. Keyed by
261    /// the per-message idempotency key carried on the publish frame; a duplicate
262    /// key is suppressed before fan-out so a subscriber receives it at most once.
263    /// Not persisted across restarts (13-L1 scope; durable dedup is deferred).
264    dedup: DedupCache,
265    conversation_supervisor: Arc<ConversationSupervisor>,
266    /// Registered custom conversation responders, keyed by conversation subject.
267    ///
268    /// When a conversation is opened (`open_conversation`), the subject is looked
269    /// up here: a registered [`ParticipantBehaviour`] becomes the conversation's
270    /// participant; with no registration the conversation falls back to the
271    /// built-in [`EchoBehaviour`], preserving the original echo semantics exactly.
272    /// This is the seam aion #13 plugs a remote worker responder into. Interior
273    /// mutability is required because the services are shared behind `&self`.
274    responders: Mutex<ResponderRegistry>,
275    next_message_id: AtomicU64,
276    next_subscription_id: AtomicU64,
277}
278
279impl LiminalConnectionServices {
280    /// Builds library-backed services from validated server configuration.
281    ///
282    /// Durable-mode channels are backed by a shared haematite event store so
283    /// their publishes are persisted and survive the graceful-shutdown flush;
284    /// ephemeral channels carry no store.
285    ///
286    /// Full-only: a config selecting the worker-front-door profile is rejected at
287    /// entry (before any store is built), so this constructor can never build full
288    /// services for a profile that forbids them. Profile-aware callers go through
289    /// [`build_connection_services`] instead.
290    ///
291    /// # Errors
292    /// Returns [`ServerError`] when the config selects a non-full profile or a
293    /// configured channel cannot be initialized.
294    pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
295        require_full_profile(config)?;
296        let store = ProductionSubsystems.durable_store(config.persistence_path.as_deref())?;
297        Self::from_config_with_store_via(config, store, &ProductionSubsystems)
298    }
299
300    /// Builds services over a caller-provided durable store.
301    ///
302    /// Used by tests that need to inspect persisted state through the same store
303    /// handle the durable channels write to.
304    ///
305    /// Full-only: rejects a worker-front-door profile at entry, exactly like
306    /// [`Self::from_config`].
307    ///
308    /// # Errors
309    /// Returns [`ServerError`] when the config selects a non-full profile or a
310    /// configured channel cannot be initialized.
311    pub fn from_config_with_store(
312        config: &ServerConfig,
313        durable_store: Arc<dyn DurableStore>,
314    ) -> Result<Self, ServerError> {
315        require_full_profile(config)?;
316        Self::from_config_with_store_via(config, durable_store, &ProductionSubsystems)
317    }
318
319    /// [`Self::from_config_with_store`] with the subsystem factory injected.
320    ///
321    /// The channel supervisor and conversation supervisor are constructed ONLY
322    /// through `subsystems` — there is no direct constructor call in this body —
323    /// so a factory that records as a side effect of constructing cannot have its
324    /// recording omitted (§9 D2 seam census, record-by-construction). No profile
325    /// check here: the caller (the public wrapper or the profile dispatch in
326    /// [`build_connection_services`]) has already established the full profile.
327    fn from_config_with_store_via(
328        config: &ServerConfig,
329        durable_store: Arc<dyn DurableStore>,
330        subsystems: &dyn SubsystemFactory,
331    ) -> Result<Self, ServerError> {
332        // Build ONE shared channel supervisor for the whole server. When a
333        // [cluster] section is present it is distribution-enabled, so every
334        // channel actor and subscriber shares the clustered scheduler the cluster
335        // attaches its process-group transport to (SRV-005, Constraint B).
336        let cluster = subsystems.channel_cluster(config.cluster.as_ref())?;
337        let mut channels = HashMap::new();
338        for channel in &config.channels {
339            // Resolve the channel's real JSON Schema (loaded from `schema_ref`
340            // during config validation) or the permissive empty schema when the
341            // channel declared none. The protocol schema id advertised at
342            // subscribe time is derived from the SAME schema bytes so an SDK
343            // deriving ids from schema bytes converges on it.
344            let resolved = resolve_channel_schema(channel);
345            let schema =
346                Schema::new(resolved.document).map_err(|error| ServerError::ConfigValidation {
347                    message: format!("failed to initialize channel '{}': {error}", channel.name),
348                })?;
349            let channel_config = if channel.durable {
350                ChannelConfig::new(channel.name.clone(), schema, ChannelMode::Durable)
351            } else {
352                ChannelConfig::new(channel.name.clone(), schema, ChannelMode::Ephemeral)
353            };
354            let handle = if channel.durable {
355                ChannelHandle::new_durable_with_supervisor(
356                    channel_config,
357                    Arc::clone(&durable_store),
358                    cluster.supervisor().clone(),
359                )
360                .map_err(|error| ServerError::ConfigValidation {
361                    message: format!(
362                        "failed to initialize durable channel '{}': {error}",
363                        channel.name
364                    ),
365                })?
366            } else {
367                ChannelHandle::with_supervisor(channel_config, cluster.supervisor().clone())
368            };
369            channels.insert(
370                channel.name.clone(),
371                ConfiguredChannel {
372                    handle,
373                    protocol_schema: resolved.protocol_id,
374                },
375            );
376        }
377        let conversation_supervisor = subsystems.conversation_supervisor()?;
378        let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
379        // Production participant activation (LP gap closure, Part B): the
380        // deployment's [participant] section installs the ONE production
381        // semantic handler, sealed together with the same durable store the
382        // conversation logs live in, under the configured wire-frame limit.
383        // No section, no service — the capability bit stays off and the
384        // connection path is byte-identical to the pre-activation build.
385        let participant_service = config
386            .participant
387            .as_ref()
388            .map(|participant| {
389                let handler =
390                    ProductionParticipantHandler::new(Arc::clone(&durable_store), *participant)
391                        .map_err(|error| ServerError::ParticipantStartupRestore {
392                            message: error.to_string(),
393                        })?;
394                InstalledParticipantService::new(
395                    Arc::new(handler),
396                    Arc::clone(&durable_store),
397                    participant.wire_frame_limit,
398                )
399                .map_err(|error| ServerError::ConfigValidation {
400                    message: format!(
401                        "participant.wire_frame_limit: {} is below the protocol's minimum \
402                         complete participant frame ({error:?})",
403                        participant.wire_frame_limit
404                    ),
405                })
406            })
407            .transpose()?;
408        Ok(Self {
409            channels,
410            cluster,
411            durable_store,
412            participant_service,
413            dedup,
414            conversation_supervisor,
415            responders: Mutex::new(HashMap::new()),
416            next_message_id: AtomicU64::new(1),
417            next_subscription_id: AtomicU64::new(1),
418        })
419    }
420
421    /// Builds services with no configured channels.
422    ///
423    /// # Errors
424    /// Returns [`ServerError`] when the conversation supervisor scheduler cannot start.
425    pub fn empty() -> Result<Self, ServerError> {
426        let conversation_supervisor = ProductionSubsystems.conversation_supervisor()?;
427        let durable_store = build_durable_store(None)?;
428        let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
429        Ok(Self {
430            channels: HashMap::new(),
431            cluster: build_channel_cluster(None)?,
432            durable_store,
433            participant_service: None,
434            dedup,
435            conversation_supervisor,
436            responders: Mutex::new(HashMap::new()),
437            next_message_id: AtomicU64::new(1),
438            next_subscription_id: AtomicU64::new(1),
439        })
440    }
441
442    /// The shared channel supervisor + cluster resolver backing this service.
443    ///
444    /// The server runtime uses this to attach the cluster to the channel
445    /// supervisor's clustered scheduler (SRV-005).
446    #[must_use]
447    pub const fn channel_cluster(&self) -> &ChannelCluster {
448        &self.cluster
449    }
450
451    /// Returns the shared durable store backing this service's durable channels.
452    #[must_use]
453    pub fn durable_store(&self) -> Arc<dyn DurableStore> {
454        Arc::clone(&self.durable_store)
455    }
456
457    /// Installs a complete participant bundle in full-service supervisor tests.
458    ///
459    /// Production full services intentionally stay disabled until a concrete
460    /// lifecycle handler exists. This consuming test builder exercises the real
461    /// supervisor activation path without allowing an already-shared adapter to
462    /// change capability posture.
463    #[cfg(test)]
464    #[must_use]
465    pub(crate) fn with_participant_service(
466        mut self,
467        participant_service: InstalledParticipantService,
468    ) -> Self {
469        self.participant_service = Some(participant_service);
470        self
471    }
472
473    /// Returns the conversation supervisor backing supervised conversations.
474    ///
475    /// Tests use this to reach the underlying beamr scheduler so they can spawn
476    /// or terminate participant processes and exercise crash detection.
477    #[must_use]
478    pub fn conversation_supervisor(&self) -> Arc<ConversationSupervisor> {
479        Arc::clone(&self.conversation_supervisor)
480    }
481
482    /// Registers a custom conversation responder for a routing `subject`.
483    ///
484    /// When a conversation is later opened with this exact `subject`, its
485    /// participant runs `behaviour` instead of the built-in [`EchoBehaviour`].
486    /// The responder is spawned and supervised identically to the echo
487    /// participant — a real linked beamr process with the same crash-detection
488    /// semantics — so this exposes the responder seam without changing how
489    /// participants run. Registering a subject that already has a responder
490    /// replaces it; the previous behaviour is returned.
491    ///
492    /// This is the liminal-side seam aion #13 plugs a remote worker into: it
493    /// registers a responder that forwards each request to the worker and routes
494    /// the worker's reply back through the conversation. Subjects with no
495    /// registration keep echoing, so existing callers are unaffected.
496    ///
497    /// # Errors
498    /// Returns [`ServerError`] when the responder registry lock is poisoned.
499    pub fn register_responder(
500        &self,
501        subject: impl Into<String>,
502        behaviour: Arc<dyn ParticipantBehaviour>,
503    ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
504        let mut responders = self.lock_responders()?;
505        Ok(responders.insert(subject.into(), behaviour))
506    }
507
508    /// Removes the custom responder registered for `subject`, if any.
509    ///
510    /// After removal the subject reverts to the built-in [`EchoBehaviour`] on the
511    /// next [`Self::open_conversation`]. Returns the removed behaviour when one
512    /// was registered.
513    ///
514    /// # Errors
515    /// Returns [`ServerError`] when the responder registry lock is poisoned.
516    pub fn unregister_responder(
517        &self,
518        subject: &str,
519    ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
520        let mut responders = self.lock_responders()?;
521        Ok(responders.remove(subject))
522    }
523
524    /// Resolves the responder behaviour for `subject`: the registered custom
525    /// responder when present, otherwise the built-in [`EchoBehaviour`].
526    ///
527    /// This is the single routing decision behind the seam — registered-or-echo —
528    /// so the fallback is identical to the original hard-wired echo path.
529    fn responder_for(&self, subject: &str) -> Result<Arc<dyn ParticipantBehaviour>, ServerError> {
530        let responders = self.lock_responders()?;
531        Ok(responders.get(subject).map_or_else(
532            || Arc::new(EchoBehaviour) as Arc<dyn ParticipantBehaviour>,
533            Arc::clone,
534        ))
535    }
536
537    /// Locks the responder registry, mapping a poisoned lock to a [`ServerError`]
538    /// rather than panicking (the workspace denies `unwrap`/`expect`/`panic`).
539    fn lock_responders(&self) -> Result<std::sync::MutexGuard<'_, ResponderRegistry>, ServerError> {
540        self.responders
541            .lock()
542            .map_err(|_poisoned| ServerError::ListenerAccept {
543                message: "responder registry lock poisoned".to_owned(),
544            })
545    }
546
547    /// Subscribes to a configured channel and returns the raw library
548    /// subscription handle so a test can drain the subscriber inbox directly and
549    /// observe exactly which messages reached a subscriber.
550    #[cfg(test)]
551    pub(crate) fn subscribe_handle_for_test(
552        &self,
553        channel: &str,
554    ) -> Result<liminal::channel::SubscriptionHandle, ServerError> {
555        let configured = self
556            .channels
557            .get(channel)
558            .ok_or_else(|| ServerError::ListenerAccept {
559                message: format!("channel '{channel}' is not configured"),
560            })?;
561        configured
562            .handle
563            .subscribe()
564            .map_err(|error| ServerError::ListenerAccept {
565                message: format!("liminal subscribe failed for channel '{channel}': {error}"),
566            })
567    }
568
569    /// Claims the delivery right for an idempotency key.
570    ///
571    /// Returns `Ok(true)` when this is the first publish for the key (the caller
572    /// may deliver), and `Ok(false)` when the key was already claimed/completed (a
573    /// duplicate the caller must suppress). The dedup cache is driven synchronously
574    /// over the in-memory haematite store via the durable bridge.
575    fn claim_delivery(&self, key: &str) -> Result<bool, ServerError> {
576        let decision = block_on(self.dedup.claim_or_get(key, dedup_timestamp_millis()))
577            .map_err(|error| ServerError::ListenerAccept {
578                message: format!("dedup bridge failed for key '{key}': {error}"),
579            })?
580            .map_err(|error| ServerError::ListenerAccept {
581                message: format!("dedup claim failed for key '{key}': {error}"),
582            })?;
583        Ok(matches!(decision, DedupDecision::Claimed))
584    }
585
586    /// Releases a dangling in-flight dedup claim after a failed delivery.
587    ///
588    /// Best-effort: a release failure cannot mask the original publish error, so
589    /// this returns nothing and logs at `error` level instead of surfacing. It is
590    /// never silent — the leak (a permanently suppressed key) must be observable.
591    /// `release_claim` itself never clobbers a stored receipt, so calling it on the
592    /// failure path is safe even if a concurrent completion raced ahead.
593    fn release_claim(&self, key: &str) {
594        match block_on(self.dedup.release_claim(key)) {
595            Ok(Ok(())) => {}
596            Ok(Err(error)) => {
597                tracing::error!(
598                    idempotency_key = key,
599                    %error,
600                    "failed to release dedup claim after publish failure; key may stay suppressed"
601                );
602            }
603            Err(error) => {
604                tracing::error!(
605                    idempotency_key = key,
606                    %error,
607                    "dedup release bridge failed after publish failure; key may stay suppressed"
608                );
609            }
610        }
611    }
612}
613
614/// Returns the current epoch-millis timestamp used as the dedup entry anchor.
615///
616/// A clock error before the Unix epoch yields `0`: the timestamp is only a TTL
617/// anchor for the in-memory cache and a zero anchor never breaks the at-most-once
618/// claim semantics, so this avoids surfacing a clock fault on the publish path.
619fn dedup_timestamp_millis() -> u64 {
620    use std::time::{SystemTime, UNIX_EPOCH};
621    SystemTime::now()
622        .duration_since(UNIX_EPOCH)
623        .ok()
624        .and_then(|duration| u64::try_from(duration.as_millis()).ok())
625        .unwrap_or(0)
626}
627
628/// Default shard count for an on-disk durable store.
629///
630/// Haematite routes keys across this many single-threaded shard actors; a small
631/// power of two gives parallelism across cursors/streams without spawning an
632/// actor per core. The value is fixed (haematite has no silent default) and not
633/// yet surfaced in server config.
634const DEFAULT_SHARD_COUNT: usize = 8;
635
636/// Namespace prefix for the dedup-on-delivery cache streams. Keeps delivery dedup
637/// keys from colliding with any other haematite streams in the shared store.
638const DELIVERY_DEDUP_NAMESPACE: &str = "liminal:delivery-dedup";
639
640/// Beamr-scheduler-owning subsystems the full-service construction path builds
641/// beyond the connection supervisor's own scheduler. The §9 D2 seam census counts
642/// these: the worker-front-door profile must construct NONE of them. Test-gated:
643/// this is the recording vocabulary of the gate's instrument, not production state.
644#[cfg(test)]
645#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
646pub(super) enum SchedulerSubsystem {
647    /// The shared channel supervisor (its own beamr scheduler).
648    ChannelSupervisor,
649    /// The conversation supervisor (its own beamr scheduler).
650    ConversationSupervisor,
651    /// The haematite store's database (its shard-actor scheduler).
652    HaematiteStore,
653}
654
655/// Constructor seam for every scheduler-owning subsystem (`SchedulerSubsystem`)
656/// the profile-aware construction path can create.
657///
658/// These methods are the ONLY route through which [`build_connection_services`]
659/// and the [`LiminalConnectionServices`] config constructors reach
660/// `build_channel_cluster`, `ConversationSupervisor::new`, and the durable-store
661/// constructors — no direct constructor call exists in those bodies. The §9 D2
662/// gate therefore injects a factory that records as a side effect of
663/// constructing (the D3 store-seam ownership move applied to schedulers): a
664/// recording cannot be omitted without also failing to construct the subsystem,
665/// which closes the "hand-placed census call beside the constructor" gap where a
666/// future subsystem could be built without its courtesy call.
667pub(super) trait SubsystemFactory {
668    /// Constructs the shared channel supervisor + cluster resolver (a beamr
669    /// scheduler).
670    ///
671    /// # Errors
672    /// Returns [`ServerError`] when the channel supervisor scheduler cannot start.
673    fn channel_cluster(
674        &self,
675        cluster_config: Option<&ClusterConfig>,
676    ) -> Result<ChannelCluster, ServerError>;
677
678    /// Constructs the conversation supervisor (a beamr scheduler).
679    ///
680    /// # Errors
681    /// Returns [`ServerError`] when the conversation supervisor scheduler cannot
682    /// start.
683    fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError>;
684
685    /// Constructs the durable store (haematite's shard-actor scheduler):
686    /// persistent under `persistence_path`, self-owning ephemeral otherwise.
687    ///
688    /// # Errors
689    /// Returns [`ServerError`] when the store cannot be opened.
690    fn durable_store(
691        &self,
692        persistence_path: Option<&Path>,
693    ) -> Result<Arc<dyn DurableStore>, ServerError>;
694}
695
696/// The production factory: the real constructors, recording nothing.
697pub(super) struct ProductionSubsystems;
698
699impl SubsystemFactory for ProductionSubsystems {
700    fn channel_cluster(
701        &self,
702        cluster_config: Option<&ClusterConfig>,
703    ) -> Result<ChannelCluster, ServerError> {
704        build_channel_cluster(cluster_config)
705    }
706
707    fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
708        Ok(Arc::new(ConversationSupervisor::new().map_err(
709            |error| ServerError::ConfigValidation {
710                message: format!("failed to start conversation supervisor: {error}"),
711            },
712        )?))
713    }
714
715    fn durable_store(
716        &self,
717        persistence_path: Option<&Path>,
718    ) -> Result<Arc<dyn DurableStore>, ServerError> {
719        build_durable_store(persistence_path)
720    }
721}
722
723/// Test-only recording implementation of [`SubsystemFactory`] for the §9 D2
724/// construction gate. Lives here (not in a test module's private scope) so the
725/// supervisor-level gate test reuses the same instrument.
726#[cfg(test)]
727#[allow(clippy::expect_used)]
728pub(super) mod subsystem_census {
729    use std::path::{Path, PathBuf};
730    use std::sync::{Arc, Mutex};
731
732    use liminal::conversation::ConversationSupervisor;
733    use liminal::durability::{DurableStore, open_ephemeral_rooted};
734
735    use super::{
736        ChannelCluster, DEFAULT_SHARD_COUNT, ProductionSubsystems, SchedulerSubsystem,
737        SubsystemFactory, build_durable_store_with,
738    };
739    use crate::ServerError;
740    use crate::config::types::ClusterConfig;
741
742    /// Records each [`SchedulerSubsystem`] AS A SIDE EFFECT of constructing it,
743    /// then hands back the production-constructed subsystem (with ephemeral
744    /// stores rooted in an isolated directory, so the fs half of the gate is a
745    /// real negative assertion). Because [`SubsystemFactory`] is the only route
746    /// the profile-aware construction path has to these constructors, a
747    /// recording cannot be omitted without also failing to construct — the
748    /// record-by-construction guarantee.
749    pub struct RecordingSubsystems {
750        census: Mutex<Vec<SchedulerSubsystem>>,
751        ephemeral_root: PathBuf,
752    }
753
754    impl RecordingSubsystems {
755        /// A recording factory whose ephemeral stores live under `ephemeral_root`.
756        pub fn rooted(ephemeral_root: &Path) -> Self {
757            Self {
758                census: Mutex::new(Vec::new()),
759                ephemeral_root: ephemeral_root.to_path_buf(),
760            }
761        }
762
763        /// The recorded construction census, sorted for order-independent
764        /// comparison.
765        pub fn recorded(&self) -> Vec<SchedulerSubsystem> {
766            let mut recorded = self
767                .census
768                .lock()
769                .expect("subsystem census lock is never poisoned in tests")
770                .clone();
771            recorded.sort();
772            recorded
773        }
774
775        fn record(&self, subsystem: SchedulerSubsystem) {
776            self.census
777                .lock()
778                .expect("subsystem census lock is never poisoned in tests")
779                .push(subsystem);
780        }
781    }
782
783    impl SubsystemFactory for RecordingSubsystems {
784        fn channel_cluster(
785            &self,
786            cluster_config: Option<&ClusterConfig>,
787        ) -> Result<ChannelCluster, ServerError> {
788            let cluster = ProductionSubsystems.channel_cluster(cluster_config)?;
789            self.record(SchedulerSubsystem::ChannelSupervisor);
790            Ok(cluster)
791        }
792
793        fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
794            let supervisor = ProductionSubsystems.conversation_supervisor()?;
795            self.record(SchedulerSubsystem::ConversationSupervisor);
796            Ok(supervisor)
797        }
798
799        fn durable_store(
800            &self,
801            persistence_path: Option<&Path>,
802        ) -> Result<Arc<dyn DurableStore>, ServerError> {
803            let store = build_durable_store_with(persistence_path, || {
804                open_ephemeral_rooted(&self.ephemeral_root, DEFAULT_SHARD_COUNT)
805            })?;
806            self.record(SchedulerSubsystem::HaematiteStore);
807            Ok(store)
808        }
809    }
810}
811
812/// Full-only constructor guard: rejects a config whose profile is not `Full`.
813///
814/// [`LiminalConnectionServices`]' config-based constructors call this at entry so
815/// the full service stack can never be built for a worker-front-door config —
816/// profile enforcement holds on every public construction path, not only the
817/// file-loading pipeline.
818fn require_full_profile(config: &ServerConfig) -> Result<(), ServerError> {
819    match config.services.profile()? {
820        ServiceProfile::Full => Ok(()),
821        ServiceProfile::WorkerFrontDoor => Err(ServerError::ConfigValidation {
822            message: format!(
823                "services.profile: \"{}\" cannot construct the full LiminalConnectionServices; \
824                 build profile-selected services via build_connection_services",
825                ServiceProfile::WORKER_FRONT_DOOR
826            ),
827        }),
828    }
829}
830
831/// Builds the connection-services adapter selected by `config`'s service profile.
832///
833/// `Full` builds [`LiminalConnectionServices`] (channels, conversations, durable
834/// store, dedup cache) exactly as today. `WorkerFrontDoor` builds
835/// [`WorkerFrontDoorServices`], which constructs none of that machinery. This is the
836/// single profile-dispatch authority: [`super::supervisor::ConnectionSupervisor`]'s
837/// config constructor and the standalone runtime's worker arm both route through it
838/// (the runtime's full arm stays on the explicit
839/// [`LiminalConnectionServices::from_config`] path because it also needs the shared
840/// channel cluster, which the trait object does not expose — that path is guarded
841/// full-only at entry).
842///
843/// # Errors
844/// Returns [`ServerError`] when the selected adapter cannot be constructed, the
845/// configured profile value is not recognised, or the worker-front-door profile is
846/// combined with full-only config fields.
847pub fn build_connection_services(
848    config: &ServerConfig,
849) -> Result<Arc<dyn ConnectionServices>, ServerError> {
850    build_connection_services_via(config, &ProductionSubsystems)
851}
852
853/// [`build_connection_services`] with the subsystem factory injected — the §9 D2
854/// gate seam, both halves at once.
855///
856/// Every scheduler-owning subsystem the `Full` branch creates is constructed
857/// through `subsystems` and nowhere else, so a recording factory observes exactly
858/// what was built (thread half, record-by-construction); the gate's factory also
859/// roots its ephemeral stores in an isolated directory, so "the root stays empty
860/// on the worker branch" is a real negative assertion (fs half, the D3 pattern).
861/// The `WorkerFrontDoor` branch never touches the factory — a regression that gave
862/// the front door any subsystem would both record in the census and land a store
863/// directory in the injected root.
864///
865/// The worker branch re-runs the cross-field checks here, not only in file-loading
866/// validation, so a directly-constructed config cannot smuggle full-only machinery
867/// past the profile.
868pub(super) fn build_connection_services_via(
869    config: &ServerConfig,
870    subsystems: &dyn SubsystemFactory,
871) -> Result<Arc<dyn ConnectionServices>, ServerError> {
872    match config.services.profile()? {
873        ServiceProfile::Full => {
874            let store = subsystems.durable_store(config.persistence_path.as_deref())?;
875            Ok(Arc::new(
876                LiminalConnectionServices::from_config_with_store_via(config, store, subsystems)?,
877            ))
878        }
879        ServiceProfile::WorkerFrontDoor => {
880            let errors = crate::config::validation::worker_front_door_field_errors(config);
881            if !errors.is_empty() {
882                return Err(ServerError::ConfigValidation {
883                    message: errors.join("; "),
884                });
885            }
886            Ok(Arc::new(WorkerFrontDoorServices::new()))
887        }
888    }
889}
890
891/// Builds the haematite-backed durable store.
892///
893/// When `persistence_path` is `Some`, the database lives there and survives
894/// process restarts: an existing database directory is reopened, a fresh one is
895/// created. When it is `None` (no durable path configured, or the channel-free
896/// `empty()` services used by tests), a self-owning ephemeral store is opened
897/// instead: its temporary directory is created and removed by the store itself
898/// (D3), so it leaves no residue once the last store handle drops. The two paths
899/// return distinct concrete stores on purpose — only the ephemeral one carries a
900/// directory guard; the persistent path is untouched.
901fn build_durable_store(
902    persistence_path: Option<&Path>,
903) -> Result<Arc<dyn DurableStore>, ServerError> {
904    build_durable_store_with(persistence_path, || open_ephemeral(DEFAULT_SHARD_COUNT))
905}
906
907/// [`build_durable_store`] with the ephemeral factory injected.
908///
909/// The split exists for the D3 construction gates: the SAME branch logic runs
910/// in production and tests, and only the factory closure differs — tests root
911/// the ephemeral store in an isolated directory (via liminal's test-gated
912/// rooted factory) so "no ephemeral directory was created" is a real assertion
913/// rather than a scan of the shared system temp dir.
914fn build_durable_store_with(
915    persistence_path: Option<&Path>,
916    make_ephemeral: impl FnOnce() -> Result<EphemeralHaematiteStore, DurabilityError>,
917) -> Result<Arc<dyn DurableStore>, ServerError> {
918    let Some(path) = persistence_path else {
919        let store = make_ephemeral().map_err(|error| ServerError::ConfigValidation {
920            message: format!("failed to open ephemeral durable store: {error}"),
921        })?;
922        return Ok(Arc::new(store));
923    };
924    let data_dir = path.join("durability");
925    let database = open_or_create_database(&data_dir)?;
926    let event_store = EventStore::new(database);
927    Ok(Arc::new(HaematiteStore::new(Arc::new(event_store))))
928}
929
930/// Opens an existing haematite database at `data_dir`, or creates one.
931fn open_or_create_database(data_dir: &Path) -> Result<Database, ServerError> {
932    let config_file = data_dir.join("config.json");
933    let result = if config_file.exists() {
934        Database::open(data_dir)
935    } else {
936        Database::create(DatabaseConfig {
937            data_dir: data_dir.to_path_buf(),
938            shard_count: DEFAULT_SHARD_COUNT,
939            distributed: None,
940        })
941    };
942    result.map_err(|error| ServerError::ConfigValidation {
943        message: format!(
944            "failed to open durable store at {}: {error}",
945            data_dir.display()
946        ),
947    })
948}
949
950impl ConnectionServices for LiminalConnectionServices {
951    fn participant_service(&self) -> Option<InstalledParticipantService> {
952        self.participant_service.clone()
953    }
954
955    fn publish(
956        &self,
957        channel: &str,
958        envelope: &MessageEnvelope,
959        idempotency_key: Option<&str>,
960    ) -> Result<PublishOutcome, ServerError> {
961        let handle = self
962            .channels
963            .get(channel)
964            .map(|configured| configured.handle.clone())
965            .ok_or_else(|| ServerError::ListenerAccept {
966                message: format!("channel '{channel}' is not configured"),
967            })?;
968
969        // Dedup-on-delivery: a publish carrying an idempotency key is delivered to
970        // subscribers AT MOST ONCE across re-publishes of the same key. Only a
971        // fresh `Claimed` decision proceeds to fan-out; a `Completed`/`InFlight`
972        // decision is a duplicate and is suppressed (no second delivery), which is
973        // the at-most-once guarantee the aion outbox relies on.
974        if let Some(key) = idempotency_key {
975            if !self.claim_delivery(key)? {
976                // A dedup-suppressed re-publish is still an accepted publish (it is
977                // assigned a message id), but it reaches no subscriber, so it counts
978                // toward publishes and not deliveries.
979                crate::metrics::publish_accepted();
980                return Ok(PublishOutcome {
981                    message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
982                    delivered: false,
983                });
984            }
985        }
986
987        let delivery = handle.publish_with_delivery(
988            &envelope.payload,
989            liminal::envelope::PublisherId::default(),
990            None,
991        );
992        let delivery = match delivery {
993            Ok(delivery) => delivery,
994            Err(error) => {
995                // The claim above appended an `InFlight` entry but the delivery
996                // failed before `complete_receipt` could run. Release the claim so
997                // the key is re-claimable; otherwise every re-publish would see
998                // `InFlight` and be suppressed forever. Best-effort: surface the
999                // ORIGINAL publish error regardless, but never swallow a release
1000                // failure silently (it leaves the leak intact).
1001                if let Some(key) = idempotency_key {
1002                    self.release_claim(key);
1003                }
1004                return Err(ServerError::ListenerAccept {
1005                    message: format!("liminal publish failed for channel '{channel}': {error}"),
1006                });
1007            }
1008        };
1009
1010        // Record the dedup completion AFTER a successful claimed delivery so the
1011        // claim is not left dangling `InFlight` (which would wrongly defer every
1012        // future duplicate). The receipt body is empty: the dedup contract here
1013        // only needs presence, not a stored result.
1014        if let Some(key) = idempotency_key {
1015            block_on(
1016                self.dedup
1017                    .complete_receipt(key, ProcessingReceipt::new(Vec::new())),
1018            )
1019            .map_err(|error| ServerError::ListenerAccept {
1020                message: format!("dedup receipt bridge failed for key '{key}': {error}"),
1021            })?
1022            .map_err(|error| ServerError::ListenerAccept {
1023                message: format!("dedup receipt write failed for key '{key}': {error}"),
1024            })?;
1025        }
1026
1027        // Record the accepted publish and its genuine subscriber deliveries. The
1028        // delivered count (0 for an empty channel) is the same signal the delivery
1029        // ack is derived from.
1030        crate::metrics::publish_accepted();
1031        let delivered_count = u64::try_from(delivery.delivered_count()).unwrap_or(u64::MAX);
1032        crate::metrics::deliveries_recorded(delivered_count);
1033
1034        Ok(PublishOutcome {
1035            message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
1036            delivered: delivery.is_delivered(),
1037        })
1038    }
1039
1040    fn subscribe(
1041        &self,
1042        channel: &str,
1043        accepted_schemas: &[ProtocolSchemaId],
1044        install: Option<liminal::channel::InboxInstall>,
1045    ) -> Result<ConnectionSubscription, ServerError> {
1046        let configured = self
1047            .channels
1048            .get(channel)
1049            .ok_or_else(|| ServerError::ListenerAccept {
1050                message: format!("channel '{channel}' is not configured"),
1051            })?;
1052        let selected_schema = if accepted_schemas.is_empty() {
1053            configured.protocol_schema
1054        } else {
1055            liminal::protocol::negotiate_schema(configured.protocol_schema, accepted_schemas)
1056                .map_err(|error| server_error_from_protocol(&error))?
1057        };
1058        // `subscribe_with_install` installs the §5 budget/fairness cap and the R3
1059        // wake notifier on the inbox at construction — strictly before the
1060        // registration is published to the channel actor — so there is no window
1061        // in which a publish can land uncharged or without a wake.
1062        let subscription = install
1063            .map_or_else(
1064                || configured.handle.subscribe(),
1065                |install| configured.handle.subscribe_with_install(install),
1066            )
1067            .map_err(|error| ServerError::ListenerAccept {
1068                message: format!("liminal subscribe failed for channel '{channel}': {error}"),
1069            })?;
1070        let id = self.next_subscription_id.fetch_add(1, Ordering::Relaxed);
1071        Ok(ConnectionSubscription::new(
1072            id,
1073            selected_schema,
1074            Box::new(LiminalSubscriptionResource { subscription }),
1075        ))
1076    }
1077
1078    fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError> {
1079        subscription.unsubscribe()
1080    }
1081
1082    fn open_conversation(
1083        &self,
1084        conversation_id: u64,
1085        subject: &str,
1086    ) -> Result<ConnectionConversation, ServerError> {
1087        // Spawn a REAL participant process (a beamr `NativeHandler` running the
1088        // resolved responder behaviour) on the conversation supervisor's
1089        // scheduler, and a supervised conversation actor linked to it. The actor
1090        // FORWARDS each conversation message to the participant, which genuinely
1091        // processes it and delivers a reply back. The actor traps the
1092        // participant's EXIT (a beamr process link), so killing it fires a
1093        // structural, microsecond-scale crash signal.
1094        //
1095        // The responder is chosen by `subject`: a custom responder registered via
1096        // `register_responder` for this subject, or the built-in `EchoBehaviour`
1097        // when none is registered. Either way it runs as the SAME supervised,
1098        // linked participant process — the seam changes WHO responds, not HOW the
1099        // participant is spawned or supervised.
1100        let behaviour = self.responder_for(subject)?;
1101        let (actor, participant) = self
1102            .conversation_supervisor
1103            .spawn_with_participant(behaviour, None, ChannelMode::Ephemeral, CrashPolicy::Fail)
1104            .map_err(|error| ServerError::ListenerAccept {
1105                message: format!(
1106                    "failed to spawn supervised conversation {conversation_id} ('{subject}'): {error}"
1107                ),
1108            })?;
1109
1110        // Drive boot to completion so the beamr link to the participant exists
1111        // before any message is forwarded (link-before-forward), mirroring the
1112        // ROUTING-004 dispatch pattern.
1113        actor.pid().map_err(|error| ServerError::ListenerAccept {
1114            message: format!(
1115                "failed to boot supervised conversation {conversation_id} ('{subject}'): {error}"
1116            ),
1117        })?;
1118
1119        // Register the structural EXIT notifier BEFORE returning, so a crash that
1120        // fires the instant a message reaches the participant is never missed.
1121        // The notifier is woken by the actor's trapped-EXIT handler (event
1122        // driven), and a crash that already landed is replayed immediately.
1123        let (exit_tx, exit_rx) = mpsc::sync_channel::<Instant>(1);
1124        actor
1125            .notify_on_participant_exit(participant, exit_tx)
1126            .map_err(|error| ServerError::ListenerAccept {
1127                message: format!(
1128                    "failed to arm crash detection for conversation {conversation_id}: {error}"
1129                ),
1130            })?;
1131
1132        Ok(ConnectionConversation::new(Box::new(
1133            LiminalConversationResource::new(actor, participant, exit_rx),
1134        )))
1135    }
1136
1137    fn conversation_message(
1138        &self,
1139        conversation: &ConnectionConversation,
1140        envelope: &MessageEnvelope,
1141    ) -> Result<(), ServerError> {
1142        conversation.message(envelope)
1143    }
1144
1145    fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError> {
1146        conversation.close()
1147    }
1148
1149    fn flush_durable_state(&self) -> Result<(), ServerError> {
1150        for (channel_name, configured) in &self.channels {
1151            if configured.handle.config().mode == ChannelMode::Durable {
1152                configured
1153                    .handle
1154                    .flush()
1155                    .map_err(|error| ServerError::ShutdownFlush {
1156                        message: format!(
1157                            "failed to flush durable channel '{channel_name}': {error}"
1158                        ),
1159                    })?;
1160            }
1161        }
1162        Ok(())
1163    }
1164}
1165
1166#[derive(Debug)]
1167struct ConfiguredChannel {
1168    handle: ChannelHandle,
1169    protocol_schema: ProtocolSchemaId,
1170}
1171
1172#[derive(Debug)]
1173struct LiminalSubscriptionResource {
1174    subscription: liminal::channel::SubscriptionHandle,
1175}
1176
1177impl SubscriptionResource for LiminalSubscriptionResource {
1178    fn unsubscribe(self: Box<Self>) -> Result<(), ServerError> {
1179        drop(self.subscription);
1180        Ok(())
1181    }
1182
1183    fn is_overflowed(&self) -> bool {
1184        self.subscription.is_overflowed()
1185    }
1186
1187    fn has_pending(&self) -> bool {
1188        self.subscription.has_pending()
1189    }
1190
1191    fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
1192        match self.subscription.try_next() {
1193            Ok(envelope) => envelope,
1194            Err(error) => {
1195                // A poisoned inbox lock is PERMANENT, not transient: once poisoned it
1196                // stays poisoned, so every future `try_next` also returns `Err` and this
1197                // subscription goes silent for the rest of its life — no further
1198                // deliveries, not "held for the next slice". Poisoning requires a panic
1199                // while the lock is held, which the workspace lints forbid
1200                // (no unwrap/expect/panic), so this is an accepted low-probability
1201                // failure rather than a recoverable one. We keep the connection alive (a
1202                // single permanently-silent subscription is less harmful than tearing
1203                // down every other subscription and stream the connection multiplexes)
1204                // but log loudly so the silence is diagnosable. The log cannot storm:
1205                // it can only fire after the one panic that poisoned the lock.
1206                tracing::error!(
1207                    %error,
1208                    "subscription inbox lock is poisoned; this subscription is now \
1209                     permanently silent and will deliver no further messages"
1210                );
1211                None
1212            }
1213        }
1214    }
1215}
1216
1217pub(super) fn server_error_from_protocol(error: &ProtocolError) -> ServerError {
1218    ServerError::ListenerAccept {
1219        message: format!("protocol operation failed: {error}"),
1220    }
1221}
1222
1223#[cfg(test)]
1224#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1225mod durable_store_tests {
1226    use liminal::durability::open_ephemeral_rooted;
1227
1228    use super::subsystem_census::RecordingSubsystems;
1229    use super::{
1230        ConnectionServices, DEFAULT_SHARD_COUNT, LiminalConnectionServices, SchedulerSubsystem,
1231        build_connection_services, build_connection_services_via, build_durable_store_with,
1232    };
1233    use crate::ServerError;
1234    use crate::config::types::{LimitsConfig, ServerConfig, ServicesConfig};
1235
1236    /// Counts directory entries under `root`, for the empty/one-dir assertions
1237    /// on an injected ephemeral root.
1238    fn entry_count(root: &std::path::Path) -> usize {
1239        std::fs::read_dir(root)
1240            .expect("ephemeral root is readable")
1241            .count()
1242    }
1243
1244    /// A minimal channel-free config with the given service `profile`. No channels,
1245    /// routing, persistence, or cluster — the shape both profiles accept (the full
1246    /// profile simply builds an empty channel set; the worker-front-door profile
1247    /// requires exactly this shape).
1248    fn config_with_profile(profile: &str) -> ServerConfig {
1249        ServerConfig {
1250            listen_address: "127.0.0.1:0".parse().expect("valid socket addr"),
1251            health_listen_address: "127.0.0.1:1".parse().expect("valid socket addr"),
1252            drain_timeout_ms: 30_000,
1253            channels: Vec::new(),
1254            routing_rules: Vec::new(),
1255            persistence_path: None,
1256            cluster: None,
1257            auth: None,
1258            services: ServicesConfig {
1259                profile: profile.to_owned(),
1260            },
1261            limits: LimitsConfig::default(),
1262            participant: None,
1263            websocket: None,
1264        }
1265    }
1266
1267    /// §9 D2 front-door construction gate (fs half): building the worker-front-door
1268    /// services creates NO haematite store and NO temp dir, while the full profile
1269    /// over an equally-rooted factory DOES create exactly one store directory.
1270    ///
1271    /// The injected root is the only place an ephemeral store directory can appear
1272    /// (the recording factory roots its stores there), so "the root stays empty on
1273    /// the front-door branch" is a real negative assertion: a regression that gave
1274    /// the front door a store would land its directory here and fail this test. The
1275    /// full-profile arm is the positive control proving the seam genuinely
1276    /// constructs a store when the profile asks for one.
1277    #[test]
1278    fn worker_front_door_builds_no_store_and_no_temp_dir() {
1279        let front_door_root = tempfile::tempdir().expect("test can create an ephemeral root");
1280        let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
1281
1282        let front_door_subsystems = RecordingSubsystems::rooted(front_door_root.path());
1283        let front_door: std::sync::Arc<dyn ConnectionServices> = build_connection_services_via(
1284            &config_with_profile("worker-front-door"),
1285            &front_door_subsystems,
1286        )
1287        .expect("worker-front-door services build");
1288        assert!(
1289            !front_door.supports_channel_operations(),
1290            "the worker front door serves no channel operations"
1291        );
1292        assert_eq!(
1293            entry_count(front_door_root.path()),
1294            0,
1295            "the worker front door creates no ephemeral store directory (no haematite, no temp dir)"
1296        );
1297
1298        let full_subsystems = RecordingSubsystems::rooted(full_root.path());
1299        let full = build_connection_services_via(&config_with_profile("full"), &full_subsystems)
1300            .expect("full services build");
1301        assert!(
1302            full.supports_channel_operations(),
1303            "full mode serves channel operations"
1304        );
1305        assert_eq!(
1306            entry_count(full_root.path()),
1307            1,
1308            "full mode with no persistence path builds exactly one ephemeral store directory"
1309        );
1310
1311        drop(front_door);
1312        drop(full);
1313    }
1314
1315    /// §9 D2 front-door construction gate (thread half — record-by-construction
1316    /// census): the worker profile constructs NO channel-supervisor,
1317    /// conversation-supervisor, or haematite scheduler, while the SAME instrument
1318    /// over the full profile records all three — the positive control proving the
1319    /// census detects the extra schedulers, so an empty census on the worker branch
1320    /// is a real observation, not a decoration.
1321    ///
1322    /// The instrument's boundary: recording happens INSIDE the [`SubsystemFactory`]
1323    /// methods that are the profile-aware path's only route to these constructors,
1324    /// so a recording cannot be silently omitted — a future subsystem added to this
1325    /// path either goes through the factory (and is recorded by construction) or
1326    /// bypasses it, which is a code-review-visible structural violation of the
1327    /// factory seam, not a silently-missing side call. The connection supervisor's
1328    /// own scheduler is the shared baseline of both profiles and is asserted at the
1329    /// supervisor level (`supervisor::tests`); an OS-level thread census upgrades
1330    /// this when the beamr composition lane's scheduler-inventory API (currently on
1331    /// their branch, not yet consumable from liminal) lands.
1332    #[test]
1333    fn worker_profile_census_is_empty_and_full_profile_records_all_schedulers() {
1334        let worker_root = tempfile::tempdir().expect("test can create an ephemeral root");
1335        let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
1336
1337        let worker_subsystems = RecordingSubsystems::rooted(worker_root.path());
1338        let front_door = build_connection_services_via(
1339            &config_with_profile("worker-front-door"),
1340            &worker_subsystems,
1341        )
1342        .expect("worker-front-door services build");
1343        assert_eq!(
1344            worker_subsystems.recorded(),
1345            Vec::<SchedulerSubsystem>::new(),
1346            "the worker front door constructs no scheduler-owning subsystem"
1347        );
1348
1349        let full_subsystems = RecordingSubsystems::rooted(full_root.path());
1350        let full = build_connection_services_via(&config_with_profile("full"), &full_subsystems)
1351            .expect("full services build");
1352        assert_eq!(
1353            full_subsystems.recorded(),
1354            vec![
1355                SchedulerSubsystem::ChannelSupervisor,
1356                SchedulerSubsystem::ConversationSupervisor,
1357                SchedulerSubsystem::HaematiteStore,
1358            ],
1359            "the full profile constructs every scheduler-owning subsystem, once each — \
1360             the positive control proving the census instrument detects them"
1361        );
1362
1363        drop(front_door);
1364        drop(full);
1365    }
1366
1367    /// MAJOR-1 regression: the full-only constructors reject a worker-front-door
1368    /// config with a typed `ConfigValidation` error AT ENTRY — no full service can
1369    /// be created through any public config-based constructor under that profile.
1370    #[test]
1371    fn full_only_constructors_reject_worker_profile() {
1372        let config = config_with_profile("worker-front-door");
1373
1374        let from_config = LiminalConnectionServices::from_config(&config);
1375        assert!(
1376            matches!(from_config, Err(ServerError::ConfigValidation { .. })),
1377            "from_config must reject a worker-front-door profile with ConfigValidation, got {from_config:?}"
1378        );
1379
1380        let root = tempfile::tempdir().expect("test can create an ephemeral root");
1381        let store = open_ephemeral_rooted(root.path(), DEFAULT_SHARD_COUNT)
1382            .expect("test store for the rejection check builds");
1383        let from_config_with_store =
1384            LiminalConnectionServices::from_config_with_store(&config, std::sync::Arc::new(store));
1385        assert!(
1386            matches!(
1387                from_config_with_store,
1388                Err(ServerError::ConfigValidation { .. })
1389            ),
1390            "from_config_with_store must reject a worker-front-door profile with ConfigValidation"
1391        );
1392    }
1393
1394    /// MAJOR-1 regression: the profile-aware factory itself re-runs the
1395    /// worker-front-door cross-field checks, so a directly-constructed config (one
1396    /// that never passed file-loading validation) combining the worker profile with
1397    /// full-only machinery is refused with the same typed `ConfigValidation` errors.
1398    #[test]
1399    fn build_connection_services_rejects_worker_profile_with_full_only_fields() {
1400        let mut config = config_with_profile("worker-front-door");
1401        config.channels = vec![crate::config::types::ChannelDef {
1402            name: "orders".to_owned(),
1403            schema_ref: None,
1404            durable: false,
1405            loaded_schema: None,
1406        }];
1407        config.persistence_path = Some(std::path::PathBuf::from("/tmp"));
1408
1409        let result = build_connection_services(&config);
1410        let Err(ServerError::ConfigValidation { message }) = result else {
1411            panic!("expected ConfigValidation for worker profile with full-only fields");
1412        };
1413        assert!(message.contains("builds no channels"), "got: {message}");
1414        assert!(
1415            message.contains("builds no durable store"),
1416            "got: {message}"
1417        );
1418    }
1419
1420    /// §9 D3 construction gate (persistent half): requesting a *persistent* store
1421    /// creates its database under the configured path and NO ephemeral directory.
1422    ///
1423    /// Exercises `build_durable_store_with` — the same branch logic production
1424    /// runs — with only the ephemeral factory swapped to root in an isolated
1425    /// directory. That root is where any ephemeral directory would have to
1426    /// appear, so "the root stays empty" is a real negative assertion — a
1427    /// regression that constructs an ephemeral store on the persistent branch
1428    /// lands its directory here and fails this test.
1429    #[test]
1430    fn persistent_store_uses_configured_path_and_creates_no_temp_dir() {
1431        let home = tempfile::tempdir().expect("test can create a temp dir");
1432        let ephemeral_root = tempfile::tempdir().expect("test can create an ephemeral root");
1433
1434        let store = build_durable_store_with(Some(home.path()), || {
1435            open_ephemeral_rooted(ephemeral_root.path(), DEFAULT_SHARD_COUNT)
1436        })
1437        .expect("persistent store builds");
1438
1439        assert!(
1440            home.path().join("durability").join("config.json").exists(),
1441            "the persistent database is created under the configured path"
1442        );
1443        assert_eq!(
1444            entry_count(ephemeral_root.path()),
1445            0,
1446            "the persistent branch creates no ephemeral guard directory"
1447        );
1448
1449        drop(store);
1450    }
1451
1452    /// Pins the wiring seam: the ephemeral (`None`) branch of the shared build
1453    /// logic goes through the guarded constructor — exactly one directory
1454    /// appears under the injected root while the store lives, and zero residue
1455    /// remains after the last handle drops.
1456    #[test]
1457    fn ephemeral_store_directory_is_owned_through_the_build_seam() {
1458        let ephemeral_root = tempfile::tempdir().expect("test can create an ephemeral root");
1459
1460        let store = build_durable_store_with(None, || {
1461            open_ephemeral_rooted(ephemeral_root.path(), DEFAULT_SHARD_COUNT)
1462        })
1463        .expect("ephemeral store builds");
1464
1465        assert_eq!(
1466            entry_count(ephemeral_root.path()),
1467            1,
1468            "the ephemeral branch creates exactly one guard directory"
1469        );
1470
1471        drop(store);
1472
1473        assert_eq!(
1474            entry_count(ephemeral_root.path()),
1475            0,
1476            "dropping the last store handle removes the guard directory — zero residue"
1477        );
1478    }
1479}