Skip to main content

liminal_server/server/connection/
services.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, mpsc};
5use std::time::Instant;
6
7use haematite::{Database, DatabaseConfig, EventStore};
8use liminal::channel::{ChannelConfig, ChannelHandle, ChannelMode, Schema};
9use liminal::conversation::{
10    ConversationSupervisor, CrashPolicy, EchoBehaviour, ParticipantBehaviour,
11};
12use liminal::durability::bridge::block_on;
13use liminal::durability::{
14    DedupCache, DedupDecision, DurableStore, HaematiteStore, ProcessingReceipt,
15};
16use liminal::protocol::{MessageEnvelope, ProtocolError, SchemaId as ProtocolSchemaId};
17
18use super::conversation::{ConnectionConversation, LiminalConversationResource};
19use super::services_cluster::build_channel_cluster;
20use super::services_schema::resolve_channel_schema;
21use crate::ServerError;
22use crate::config::types::ServerConfig;
23
24pub use super::services_cluster::ChannelCluster;
25
26/// Registry of custom conversation responders, keyed by conversation subject.
27///
28/// A registered [`ParticipantBehaviour`] becomes the participant for any
29/// conversation opened on its subject; subjects with no entry fall back to the
30/// built-in [`EchoBehaviour`].
31type ResponderRegistry = HashMap<String, Arc<dyn ParticipantBehaviour>>;
32
33/// Marker for resources retained by a connection process until unsubscribe.
34pub trait SubscriptionResource: std::fmt::Debug + Send {
35    /// Releases the library subscription resource.
36    ///
37    /// # Errors
38    /// Returns [`ServerError`] when the liminal library reports an unsubscribe failure.
39    fn unsubscribe(self: Box<Self>) -> Result<(), ServerError>;
40
41    /// Attempts to pull the next delivered envelope from the wrapped library
42    /// subscription without blocking.
43    ///
44    /// Returns `None` when the subscriber inbox is empty (or momentarily
45    /// unavailable): the connection process is the delivery pump, so a transient
46    /// empty read is simply "nothing to deliver this slice", never an error.
47    fn try_next(&mut self) -> Option<liminal::envelope::Envelope>;
48}
49
50/// Library subscription resource owned by a single connection process.
51#[derive(Debug)]
52pub struct ConnectionSubscription {
53    id: u64,
54    /// Client-chosen application stream the server delivers this subscription's
55    /// messages on (echoed on `SubscribeAck`, carried on every `Deliver`). Set by
56    /// the connection process from the `Subscribe` frame before the subscription is
57    /// stored; `0` only while momentarily unset during construction.
58    stream_id: u32,
59    selected_schema: ProtocolSchemaId,
60    resource: Box<dyn SubscriptionResource>,
61}
62
63impl ConnectionSubscription {
64    /// Creates an owned subscription resource for one connection process.
65    #[must_use]
66    pub fn new(
67        id: u64,
68        selected_schema: ProtocolSchemaId,
69        resource: Box<dyn SubscriptionResource>,
70    ) -> Self {
71        Self {
72            id,
73            stream_id: 0,
74            selected_schema,
75            resource,
76        }
77    }
78
79    /// Returns the protocol subscription id.
80    #[must_use]
81    pub const fn id(&self) -> u64 {
82        self.id
83    }
84
85    /// Records the client-chosen delivery stream id for this subscription.
86    pub(super) const fn set_stream_id(&mut self, stream_id: u32) {
87        self.stream_id = stream_id;
88    }
89
90    /// Returns the client-chosen application stream id deliveries ride on.
91    #[must_use]
92    pub(super) const fn stream_id(&self) -> u32 {
93        self.stream_id
94    }
95
96    /// Returns the schema selected for this subscription stream.
97    #[must_use]
98    pub const fn selected_schema(&self) -> ProtocolSchemaId {
99        self.selected_schema
100    }
101
102    /// Attempts to pull the next delivered envelope without blocking.
103    pub(super) fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
104        self.resource.try_next()
105    }
106
107    pub(super) fn unsubscribe(self) -> Result<(), ServerError> {
108        self.resource.unsubscribe()
109    }
110}
111
112/// Outcome of a server publish.
113///
114/// Carries the assigned message id plus a genuine delivery ack (`delivered` = the
115/// message was accepted by at least one live subscriber on this publish, after any
116/// dedup-on-delivery suppression).
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub struct PublishOutcome {
119    /// Monotonic message id assigned to the accepted publish.
120    pub message_id: u64,
121    /// Whether the message was genuinely delivered to a subscriber. `false` means
122    /// the publish was accepted but reached no subscriber (empty channel) or was
123    /// a duplicate suppressed by dedup-on-delivery.
124    pub delivered: bool,
125}
126
127/// Operations that adapt wire frames to liminal library calls.
128pub trait ConnectionServices: std::fmt::Debug + Send + Sync {
129    /// Delegates a publish request to the liminal library.
130    ///
131    /// `idempotency_key`, when `Some`, drives dedup-on-delivery: a re-publish with
132    /// the same key is delivered to subscribers at most once. The returned
133    /// [`PublishOutcome`] carries the genuine delivery ack.
134    ///
135    /// # Errors
136    /// Returns [`ServerError`] when the liminal publish operation fails.
137    fn publish(
138        &self,
139        channel: &str,
140        envelope: &MessageEnvelope,
141        idempotency_key: Option<&str>,
142    ) -> Result<PublishOutcome, ServerError>;
143
144    /// Delegates a subscribe request to the liminal library.
145    ///
146    /// # Errors
147    /// Returns [`ServerError`] when the liminal subscribe operation fails.
148    fn subscribe(
149        &self,
150        channel: &str,
151        accepted_schemas: &[ProtocolSchemaId],
152    ) -> Result<ConnectionSubscription, ServerError>;
153
154    /// Delegates unsubscribe to the liminal library.
155    ///
156    /// # Errors
157    /// Returns [`ServerError`] when the liminal unsubscribe operation fails.
158    fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError>;
159
160    /// Delegates conversation open to the liminal library.
161    ///
162    /// # Errors
163    /// Returns [`ServerError`] when the liminal conversation open operation fails.
164    fn open_conversation(
165        &self,
166        conversation_id: u64,
167        subject: &str,
168    ) -> Result<ConnectionConversation, ServerError>;
169
170    /// Delegates a conversation message to the liminal library.
171    ///
172    /// # Errors
173    /// Returns [`ServerError`] when the liminal conversation message operation fails.
174    fn conversation_message(
175        &self,
176        conversation: &ConnectionConversation,
177        envelope: &MessageEnvelope,
178    ) -> Result<(), ServerError>;
179
180    /// Delegates conversation close to the liminal library.
181    ///
182    /// # Errors
183    /// Returns [`ServerError`] when the liminal conversation close operation fails.
184    fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError>;
185
186    /// Flushes durable channel state through the liminal library boundary.
187    ///
188    /// # Errors
189    /// Returns [`ServerError`] when the liminal channel flush operation fails.
190    fn flush_durable_state(&self) -> Result<(), ServerError>;
191}
192
193/// Default adapter from server wire frames to liminal channel/conversation APIs.
194#[derive(Debug)]
195pub struct LiminalConnectionServices {
196    channels: HashMap<String, ConfiguredChannel>,
197    cluster: ChannelCluster,
198    durable_store: Arc<dyn DurableStore>,
199    /// In-memory (haematite-backed) dedup cache for dedup-on-delivery. Keyed by
200    /// the per-message idempotency key carried on the publish frame; a duplicate
201    /// key is suppressed before fan-out so a subscriber receives it at most once.
202    /// Not persisted across restarts (13-L1 scope; durable dedup is deferred).
203    dedup: DedupCache,
204    conversation_supervisor: Arc<ConversationSupervisor>,
205    /// Registered custom conversation responders, keyed by conversation subject.
206    ///
207    /// When a conversation is opened (`open_conversation`), the subject is looked
208    /// up here: a registered [`ParticipantBehaviour`] becomes the conversation's
209    /// participant; with no registration the conversation falls back to the
210    /// built-in [`EchoBehaviour`], preserving the original echo semantics exactly.
211    /// This is the seam aion #13 plugs a remote worker responder into. Interior
212    /// mutability is required because the services are shared behind `&self`.
213    responders: Mutex<ResponderRegistry>,
214    next_message_id: AtomicU64,
215    next_subscription_id: AtomicU64,
216}
217
218impl LiminalConnectionServices {
219    /// Builds library-backed services from validated server configuration.
220    ///
221    /// Durable-mode channels are backed by a shared haematite event store so
222    /// their publishes are persisted and survive the graceful-shutdown flush;
223    /// ephemeral channels carry no store.
224    ///
225    /// # Errors
226    /// Returns [`ServerError`] when a configured channel cannot be initialized.
227    pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
228        let store = build_durable_store(config.persistence_path.as_deref())?;
229        Self::from_config_with_store(config, store)
230    }
231
232    /// Builds services over a caller-provided durable store.
233    ///
234    /// Used by tests that need to inspect persisted state through the same store
235    /// handle the durable channels write to.
236    ///
237    /// # Errors
238    /// Returns [`ServerError`] when a configured channel cannot be initialized.
239    pub fn from_config_with_store(
240        config: &ServerConfig,
241        durable_store: Arc<dyn DurableStore>,
242    ) -> Result<Self, ServerError> {
243        // Build ONE shared channel supervisor for the whole server. When a
244        // [cluster] section is present it is distribution-enabled, so every
245        // channel actor and subscriber shares the clustered scheduler the cluster
246        // attaches its process-group transport to (SRV-005, Constraint B).
247        let cluster = build_channel_cluster(config.cluster.as_ref())?;
248        let mut channels = HashMap::new();
249        for channel in &config.channels {
250            // Resolve the channel's real JSON Schema (loaded from `schema_ref`
251            // during config validation) or the permissive empty schema when the
252            // channel declared none. The protocol schema id advertised at
253            // subscribe time is derived from the SAME schema bytes so an SDK
254            // deriving ids from schema bytes converges on it.
255            let resolved = resolve_channel_schema(channel);
256            let schema =
257                Schema::new(resolved.document).map_err(|error| ServerError::ConfigValidation {
258                    message: format!("failed to initialize channel '{}': {error}", channel.name),
259                })?;
260            let channel_config = if channel.durable {
261                ChannelConfig::new(channel.name.clone(), schema, ChannelMode::Durable)
262            } else {
263                ChannelConfig::new(channel.name.clone(), schema, ChannelMode::Ephemeral)
264            };
265            let handle = if channel.durable {
266                ChannelHandle::new_durable_with_supervisor(
267                    channel_config,
268                    Arc::clone(&durable_store),
269                    cluster.supervisor().clone(),
270                )
271                .map_err(|error| ServerError::ConfigValidation {
272                    message: format!(
273                        "failed to initialize durable channel '{}': {error}",
274                        channel.name
275                    ),
276                })?
277            } else {
278                ChannelHandle::with_supervisor(channel_config, cluster.supervisor().clone())
279            };
280            channels.insert(
281                channel.name.clone(),
282                ConfiguredChannel {
283                    handle,
284                    protocol_schema: resolved.protocol_id,
285                },
286            );
287        }
288        let conversation_supervisor = Arc::new(ConversationSupervisor::new().map_err(|error| {
289            ServerError::ConfigValidation {
290                message: format!("failed to start conversation supervisor: {error}"),
291            }
292        })?);
293        let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
294        Ok(Self {
295            channels,
296            cluster,
297            durable_store,
298            dedup,
299            conversation_supervisor,
300            responders: Mutex::new(HashMap::new()),
301            next_message_id: AtomicU64::new(1),
302            next_subscription_id: AtomicU64::new(1),
303        })
304    }
305
306    /// Builds services with no configured channels.
307    ///
308    /// # Errors
309    /// Returns [`ServerError`] when the conversation supervisor scheduler cannot start.
310    pub fn empty() -> Result<Self, ServerError> {
311        let conversation_supervisor = Arc::new(ConversationSupervisor::new().map_err(|error| {
312            ServerError::ConfigValidation {
313                message: format!("failed to start conversation supervisor: {error}"),
314            }
315        })?);
316        let durable_store = build_durable_store(None)?;
317        let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
318        Ok(Self {
319            channels: HashMap::new(),
320            cluster: build_channel_cluster(None)?,
321            durable_store,
322            dedup,
323            conversation_supervisor,
324            responders: Mutex::new(HashMap::new()),
325            next_message_id: AtomicU64::new(1),
326            next_subscription_id: AtomicU64::new(1),
327        })
328    }
329
330    /// The shared channel supervisor + cluster resolver backing this service.
331    ///
332    /// The server runtime uses this to attach the cluster to the channel
333    /// supervisor's clustered scheduler (SRV-005).
334    #[must_use]
335    pub const fn channel_cluster(&self) -> &ChannelCluster {
336        &self.cluster
337    }
338
339    /// Returns the shared durable store backing this service's durable channels.
340    #[must_use]
341    pub fn durable_store(&self) -> Arc<dyn DurableStore> {
342        Arc::clone(&self.durable_store)
343    }
344
345    /// Returns the conversation supervisor backing supervised conversations.
346    ///
347    /// Tests use this to reach the underlying beamr scheduler so they can spawn
348    /// or terminate participant processes and exercise crash detection.
349    #[must_use]
350    pub fn conversation_supervisor(&self) -> Arc<ConversationSupervisor> {
351        Arc::clone(&self.conversation_supervisor)
352    }
353
354    /// Registers a custom conversation responder for a routing `subject`.
355    ///
356    /// When a conversation is later opened with this exact `subject`, its
357    /// participant runs `behaviour` instead of the built-in [`EchoBehaviour`].
358    /// The responder is spawned and supervised identically to the echo
359    /// participant — a real linked beamr process with the same crash-detection
360    /// semantics — so this exposes the responder seam without changing how
361    /// participants run. Registering a subject that already has a responder
362    /// replaces it; the previous behaviour is returned.
363    ///
364    /// This is the liminal-side seam aion #13 plugs a remote worker into: it
365    /// registers a responder that forwards each request to the worker and routes
366    /// the worker's reply back through the conversation. Subjects with no
367    /// registration keep echoing, so existing callers are unaffected.
368    ///
369    /// # Errors
370    /// Returns [`ServerError`] when the responder registry lock is poisoned.
371    pub fn register_responder(
372        &self,
373        subject: impl Into<String>,
374        behaviour: Arc<dyn ParticipantBehaviour>,
375    ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
376        let mut responders = self.lock_responders()?;
377        Ok(responders.insert(subject.into(), behaviour))
378    }
379
380    /// Removes the custom responder registered for `subject`, if any.
381    ///
382    /// After removal the subject reverts to the built-in [`EchoBehaviour`] on the
383    /// next [`Self::open_conversation`]. Returns the removed behaviour when one
384    /// was registered.
385    ///
386    /// # Errors
387    /// Returns [`ServerError`] when the responder registry lock is poisoned.
388    pub fn unregister_responder(
389        &self,
390        subject: &str,
391    ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
392        let mut responders = self.lock_responders()?;
393        Ok(responders.remove(subject))
394    }
395
396    /// Resolves the responder behaviour for `subject`: the registered custom
397    /// responder when present, otherwise the built-in [`EchoBehaviour`].
398    ///
399    /// This is the single routing decision behind the seam — registered-or-echo —
400    /// so the fallback is identical to the original hard-wired echo path.
401    fn responder_for(&self, subject: &str) -> Result<Arc<dyn ParticipantBehaviour>, ServerError> {
402        let responders = self.lock_responders()?;
403        Ok(responders.get(subject).map_or_else(
404            || Arc::new(EchoBehaviour) as Arc<dyn ParticipantBehaviour>,
405            Arc::clone,
406        ))
407    }
408
409    /// Locks the responder registry, mapping a poisoned lock to a [`ServerError`]
410    /// rather than panicking (the workspace denies `unwrap`/`expect`/`panic`).
411    fn lock_responders(&self) -> Result<std::sync::MutexGuard<'_, ResponderRegistry>, ServerError> {
412        self.responders
413            .lock()
414            .map_err(|_poisoned| ServerError::ListenerAccept {
415                message: "responder registry lock poisoned".to_owned(),
416            })
417    }
418
419    /// Subscribes to a configured channel and returns the raw library
420    /// subscription handle so a test can drain the subscriber inbox directly and
421    /// observe exactly which messages reached a subscriber.
422    #[cfg(test)]
423    pub(crate) fn subscribe_handle_for_test(
424        &self,
425        channel: &str,
426    ) -> Result<liminal::channel::SubscriptionHandle, ServerError> {
427        let configured = self
428            .channels
429            .get(channel)
430            .ok_or_else(|| ServerError::ListenerAccept {
431                message: format!("channel '{channel}' is not configured"),
432            })?;
433        configured
434            .handle
435            .subscribe()
436            .map_err(|error| ServerError::ListenerAccept {
437                message: format!("liminal subscribe failed for channel '{channel}': {error}"),
438            })
439    }
440
441    /// Claims the delivery right for an idempotency key.
442    ///
443    /// Returns `Ok(true)` when this is the first publish for the key (the caller
444    /// may deliver), and `Ok(false)` when the key was already claimed/completed (a
445    /// duplicate the caller must suppress). The dedup cache is driven synchronously
446    /// over the in-memory haematite store via the durable bridge.
447    fn claim_delivery(&self, key: &str) -> Result<bool, ServerError> {
448        let decision = block_on(self.dedup.claim_or_get(key, dedup_timestamp_millis()))
449            .map_err(|error| ServerError::ListenerAccept {
450                message: format!("dedup bridge failed for key '{key}': {error}"),
451            })?
452            .map_err(|error| ServerError::ListenerAccept {
453                message: format!("dedup claim failed for key '{key}': {error}"),
454            })?;
455        Ok(matches!(decision, DedupDecision::Claimed))
456    }
457
458    /// Releases a dangling in-flight dedup claim after a failed delivery.
459    ///
460    /// Best-effort: a release failure cannot mask the original publish error, so
461    /// this returns nothing and logs at `error` level instead of surfacing. It is
462    /// never silent — the leak (a permanently suppressed key) must be observable.
463    /// `release_claim` itself never clobbers a stored receipt, so calling it on the
464    /// failure path is safe even if a concurrent completion raced ahead.
465    fn release_claim(&self, key: &str) {
466        match block_on(self.dedup.release_claim(key)) {
467            Ok(Ok(())) => {}
468            Ok(Err(error)) => {
469                tracing::error!(
470                    idempotency_key = key,
471                    %error,
472                    "failed to release dedup claim after publish failure; key may stay suppressed"
473                );
474            }
475            Err(error) => {
476                tracing::error!(
477                    idempotency_key = key,
478                    %error,
479                    "dedup release bridge failed after publish failure; key may stay suppressed"
480                );
481            }
482        }
483    }
484}
485
486/// Returns the current epoch-millis timestamp used as the dedup entry anchor.
487///
488/// A clock error before the Unix epoch yields `0`: the timestamp is only a TTL
489/// anchor for the in-memory cache and a zero anchor never breaks the at-most-once
490/// claim semantics, so this avoids surfacing a clock fault on the publish path.
491fn dedup_timestamp_millis() -> u64 {
492    use std::time::{SystemTime, UNIX_EPOCH};
493    SystemTime::now()
494        .duration_since(UNIX_EPOCH)
495        .ok()
496        .and_then(|duration| u64::try_from(duration.as_millis()).ok())
497        .unwrap_or(0)
498}
499
500/// Default shard count for an on-disk durable store.
501///
502/// Haematite routes keys across this many single-threaded shard actors; a small
503/// power of two gives parallelism across cursors/streams without spawning an
504/// actor per core. The value is fixed (haematite has no silent default) and not
505/// yet surfaced in server config.
506const DEFAULT_SHARD_COUNT: usize = 8;
507
508/// Namespace prefix for the dedup-on-delivery cache streams. Keeps delivery dedup
509/// keys from colliding with any other haematite streams in the shared store.
510const DELIVERY_DEDUP_NAMESPACE: &str = "liminal:delivery-dedup";
511
512/// Builds the on-disk haematite-backed durable store.
513///
514/// When `persistence_path` is `Some`, the database lives there and survives
515/// process restarts: an existing database directory is reopened, a fresh one is
516/// created. When it is `None` (no durable path configured, or the channel-free
517/// `empty()` services used by tests), an ephemeral per-instance directory under
518/// the system temp dir is created instead — it still persists to disk for the
519/// lifetime of the process, but is not a stable restart location.
520fn build_durable_store(
521    persistence_path: Option<&Path>,
522) -> Result<Arc<dyn DurableStore>, ServerError> {
523    let data_dir = persistence_path.map_or_else(ephemeral_data_dir, |path| path.join("durability"));
524    let database = open_or_create_database(&data_dir)?;
525    let event_store = EventStore::new(database);
526    Ok(Arc::new(HaematiteStore::new(Arc::new(event_store))))
527}
528
529/// Opens an existing haematite database at `data_dir`, or creates one.
530fn open_or_create_database(data_dir: &Path) -> Result<Database, ServerError> {
531    let config_file = data_dir.join("config.json");
532    let result = if config_file.exists() {
533        Database::open(data_dir)
534    } else {
535        Database::create(DatabaseConfig {
536            data_dir: data_dir.to_path_buf(),
537            shard_count: DEFAULT_SHARD_COUNT,
538            sweep_interval: None,
539            distributed: None,
540        })
541    };
542    result.map_err(|error| ServerError::ConfigValidation {
543        message: format!(
544            "failed to open durable store at {}: {error}",
545            data_dir.display()
546        ),
547    })
548}
549
550/// Produces a unique on-disk directory under the system temp dir.
551///
552/// The pid plus a monotonic counter keep concurrent servers and parallel tests
553/// from sharing a database directory.
554fn ephemeral_data_dir() -> PathBuf {
555    static COUNTER: AtomicU64 = AtomicU64::new(0);
556    let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
557    std::env::temp_dir().join(format!(
558        "liminal-durability-{}-{unique}",
559        std::process::id()
560    ))
561}
562
563impl ConnectionServices for LiminalConnectionServices {
564    fn publish(
565        &self,
566        channel: &str,
567        envelope: &MessageEnvelope,
568        idempotency_key: Option<&str>,
569    ) -> Result<PublishOutcome, ServerError> {
570        let handle = self
571            .channels
572            .get(channel)
573            .map(|configured| configured.handle.clone())
574            .ok_or_else(|| ServerError::ListenerAccept {
575                message: format!("channel '{channel}' is not configured"),
576            })?;
577
578        // Dedup-on-delivery: a publish carrying an idempotency key is delivered to
579        // subscribers AT MOST ONCE across re-publishes of the same key. Only a
580        // fresh `Claimed` decision proceeds to fan-out; a `Completed`/`InFlight`
581        // decision is a duplicate and is suppressed (no second delivery), which is
582        // the at-most-once guarantee the aion outbox relies on.
583        if let Some(key) = idempotency_key {
584            if !self.claim_delivery(key)? {
585                // A dedup-suppressed re-publish is still an accepted publish (it is
586                // assigned a message id), but it reaches no subscriber, so it counts
587                // toward publishes and not deliveries.
588                crate::metrics::publish_accepted();
589                return Ok(PublishOutcome {
590                    message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
591                    delivered: false,
592                });
593            }
594        }
595
596        let delivery = handle.publish_with_delivery(
597            &envelope.payload,
598            liminal::envelope::PublisherId::default(),
599            None,
600        );
601        let delivery = match delivery {
602            Ok(delivery) => delivery,
603            Err(error) => {
604                // The claim above appended an `InFlight` entry but the delivery
605                // failed before `complete_receipt` could run. Release the claim so
606                // the key is re-claimable; otherwise every re-publish would see
607                // `InFlight` and be suppressed forever. Best-effort: surface the
608                // ORIGINAL publish error regardless, but never swallow a release
609                // failure silently (it leaves the leak intact).
610                if let Some(key) = idempotency_key {
611                    self.release_claim(key);
612                }
613                return Err(ServerError::ListenerAccept {
614                    message: format!("liminal publish failed for channel '{channel}': {error}"),
615                });
616            }
617        };
618
619        // Record the dedup completion AFTER a successful claimed delivery so the
620        // claim is not left dangling `InFlight` (which would wrongly defer every
621        // future duplicate). The receipt body is empty: the dedup contract here
622        // only needs presence, not a stored result.
623        if let Some(key) = idempotency_key {
624            block_on(
625                self.dedup
626                    .complete_receipt(key, ProcessingReceipt::new(Vec::new())),
627            )
628            .map_err(|error| ServerError::ListenerAccept {
629                message: format!("dedup receipt bridge failed for key '{key}': {error}"),
630            })?
631            .map_err(|error| ServerError::ListenerAccept {
632                message: format!("dedup receipt write failed for key '{key}': {error}"),
633            })?;
634        }
635
636        // Record the accepted publish and its genuine subscriber deliveries. The
637        // delivered count (0 for an empty channel) is the same signal the delivery
638        // ack is derived from.
639        crate::metrics::publish_accepted();
640        let delivered_count = u64::try_from(delivery.delivered_count()).unwrap_or(u64::MAX);
641        crate::metrics::deliveries_recorded(delivered_count);
642
643        Ok(PublishOutcome {
644            message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
645            delivered: delivery.is_delivered(),
646        })
647    }
648
649    fn subscribe(
650        &self,
651        channel: &str,
652        accepted_schemas: &[ProtocolSchemaId],
653    ) -> Result<ConnectionSubscription, ServerError> {
654        let configured = self
655            .channels
656            .get(channel)
657            .ok_or_else(|| ServerError::ListenerAccept {
658                message: format!("channel '{channel}' is not configured"),
659            })?;
660        let selected_schema = if accepted_schemas.is_empty() {
661            configured.protocol_schema
662        } else {
663            liminal::protocol::negotiate_schema(configured.protocol_schema, accepted_schemas)
664                .map_err(|error| server_error_from_protocol(&error))?
665        };
666        let subscription =
667            configured
668                .handle
669                .subscribe()
670                .map_err(|error| ServerError::ListenerAccept {
671                    message: format!("liminal subscribe failed for channel '{channel}': {error}"),
672                })?;
673        let id = self.next_subscription_id.fetch_add(1, Ordering::Relaxed);
674        Ok(ConnectionSubscription::new(
675            id,
676            selected_schema,
677            Box::new(LiminalSubscriptionResource { subscription }),
678        ))
679    }
680
681    fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError> {
682        subscription.unsubscribe()
683    }
684
685    fn open_conversation(
686        &self,
687        conversation_id: u64,
688        subject: &str,
689    ) -> Result<ConnectionConversation, ServerError> {
690        // Spawn a REAL participant process (a beamr `NativeHandler` running the
691        // resolved responder behaviour) on the conversation supervisor's
692        // scheduler, and a supervised conversation actor linked to it. The actor
693        // FORWARDS each conversation message to the participant, which genuinely
694        // processes it and delivers a reply back. The actor traps the
695        // participant's EXIT (a beamr process link), so killing it fires a
696        // structural, microsecond-scale crash signal.
697        //
698        // The responder is chosen by `subject`: a custom responder registered via
699        // `register_responder` for this subject, or the built-in `EchoBehaviour`
700        // when none is registered. Either way it runs as the SAME supervised,
701        // linked participant process — the seam changes WHO responds, not HOW the
702        // participant is spawned or supervised.
703        let behaviour = self.responder_for(subject)?;
704        let (actor, participant) = self
705            .conversation_supervisor
706            .spawn_with_participant(behaviour, None, ChannelMode::Ephemeral, CrashPolicy::Fail)
707            .map_err(|error| ServerError::ListenerAccept {
708                message: format!(
709                    "failed to spawn supervised conversation {conversation_id} ('{subject}'): {error}"
710                ),
711            })?;
712
713        // Drive boot to completion so the beamr link to the participant exists
714        // before any message is forwarded (link-before-forward), mirroring the
715        // ROUTING-004 dispatch pattern.
716        actor.pid().map_err(|error| ServerError::ListenerAccept {
717            message: format!(
718                "failed to boot supervised conversation {conversation_id} ('{subject}'): {error}"
719            ),
720        })?;
721
722        // Register the structural EXIT notifier BEFORE returning, so a crash that
723        // fires the instant a message reaches the participant is never missed.
724        // The notifier is woken by the actor's trapped-EXIT handler (event
725        // driven), and a crash that already landed is replayed immediately.
726        let (exit_tx, exit_rx) = mpsc::sync_channel::<Instant>(1);
727        actor
728            .notify_on_participant_exit(participant, exit_tx)
729            .map_err(|error| ServerError::ListenerAccept {
730                message: format!(
731                    "failed to arm crash detection for conversation {conversation_id}: {error}"
732                ),
733            })?;
734
735        Ok(ConnectionConversation::new(Box::new(
736            LiminalConversationResource::new(actor, participant, exit_rx),
737        )))
738    }
739
740    fn conversation_message(
741        &self,
742        conversation: &ConnectionConversation,
743        envelope: &MessageEnvelope,
744    ) -> Result<(), ServerError> {
745        conversation.message(envelope)
746    }
747
748    fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError> {
749        conversation.close()
750    }
751
752    fn flush_durable_state(&self) -> Result<(), ServerError> {
753        for (channel_name, configured) in &self.channels {
754            if configured.handle.config().mode == ChannelMode::Durable {
755                configured
756                    .handle
757                    .flush()
758                    .map_err(|error| ServerError::ShutdownFlush {
759                        message: format!(
760                            "failed to flush durable channel '{channel_name}': {error}"
761                        ),
762                    })?;
763            }
764        }
765        Ok(())
766    }
767}
768
769#[derive(Debug)]
770struct ConfiguredChannel {
771    handle: ChannelHandle,
772    protocol_schema: ProtocolSchemaId,
773}
774
775#[derive(Debug)]
776struct LiminalSubscriptionResource {
777    subscription: liminal::channel::SubscriptionHandle,
778}
779
780impl SubscriptionResource for LiminalSubscriptionResource {
781    fn unsubscribe(self: Box<Self>) -> Result<(), ServerError> {
782        drop(self.subscription);
783        Ok(())
784    }
785
786    fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
787        match self.subscription.try_next() {
788            Ok(envelope) => envelope,
789            Err(error) => {
790                // A poisoned inbox lock is PERMANENT, not transient: once poisoned it
791                // stays poisoned, so every future `try_next` also returns `Err` and this
792                // subscription goes silent for the rest of its life — no further
793                // deliveries, not "held for the next slice". Poisoning requires a panic
794                // while the lock is held, which the workspace lints forbid
795                // (no unwrap/expect/panic), so this is an accepted low-probability
796                // failure rather than a recoverable one. We keep the connection alive (a
797                // single permanently-silent subscription is less harmful than tearing
798                // down every other subscription and stream the connection multiplexes)
799                // but log loudly so the silence is diagnosable. The log cannot storm:
800                // it can only fire after the one panic that poisoned the lock.
801                tracing::error!(
802                    %error,
803                    "subscription inbox lock is poisoned; this subscription is now \
804                     permanently silent and will deliver no further messages"
805                );
806                None
807            }
808        }
809    }
810}
811
812pub(super) fn server_error_from_protocol(error: &ProtocolError) -> ServerError {
813    ServerError::ListenerAccept {
814        message: format!("protocol operation failed: {error}"),
815    }
816}