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