Skip to main content

liminal_server/server/connection/
services.rs

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