Skip to main content

liminal/channel/
types.rs

1//! Public channel surface: [`ChannelConfig`], [`ChannelMode`], and the
2//! cloneable [`ChannelHandle`] that drives a REAL supervised beamr channel
3//! actor (LIM-002).
4//!
5//! The handle is a thin, synchronous-looking facade over the process-backed
6//! actor: every operation enqueues a typed command onto the actor's mailbox and
7//! blocks on a per-command reply (the haematite `ShardHandle` pattern). It owns
8//! no subscriber state and performs no fan-out itself — the actor process does.
9//!
10//! `ChannelHandle::new` stays infallible (existing call-sites depend on it): the
11//! actor is spawned lazily on first use and the spawn result is memoised, so a
12//! scheduler failure surfaces as a `LiminalError` from the first operation
13//! rather than as a panic.
14
15use std::sync::atomic::AtomicU32;
16use std::sync::{Arc, Mutex, OnceLock};
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18
19use serde_json::Value;
20
21use crate::causal::CausalContext;
22use crate::channel::actor::{ChannelActorCore, PendingPublish, predicate_from};
23use crate::channel::admission::{
24    ChannelPressureConfig, defer_after_append, defer_delay, watermark_reached,
25};
26use crate::channel::observer::ClusterObserver;
27use crate::channel::schema::{Schema, SchemaId, SchemaValidationError};
28use crate::channel::subscription::{InboxInstall, SubscriptionHandle, SubscriptionPredicate};
29use crate::channel::supervisor::{ChannelSupervisor, shared_supervisor};
30use crate::durability::bridge::block_on;
31use crate::durability::{DurableChannel, DurableStore, MessageEnvelope, recover_durable_channel};
32use crate::envelope::{Envelope, PublisherId};
33use crate::error::LiminalError;
34use crate::pressure::PressureSignal;
35
36/// Single-partition count used to back a flat runtime channel with durable storage.
37const RUNTIME_DURABLE_PARTITIONS: usize = 1;
38
39/// Genuine delivery ack returned by [`ChannelHandle::publish_with_delivery`].
40///
41/// Distinct from backpressure: it reports whether a published message was
42/// actually received by a subscriber, not whether it was admitted to the bus.
43///
44/// `Copy` is preserved from published `liminal-rs 0.9.1`. A1 added two fields;
45/// the one that briefly cost `Copy` was `signal: PressureSignal`, which is four
46/// `usize`s and now derives `Copy` itself (`durable_position: Option<u64>` and
47/// `pressure_config: ChannelPressureConfig` were already `Copy`). Nothing about
48/// this type wants move semantics, and taking `Copy` away from a published type
49/// breaks callers silently — `let a = delivery; use(delivery);` simply stops
50/// compiling.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct ChannelDelivery {
53    delivered_count: usize,
54    durable_position: Option<u64>,
55    signal: PressureSignal,
56    pressure_config: ChannelPressureConfig,
57}
58
59impl ChannelDelivery {
60    /// Number of local subscribers the message was genuinely delivered to.
61    #[must_use]
62    pub const fn delivered_count(&self) -> usize {
63        self.delivered_count
64    }
65
66    /// Whether the message was accepted by at least one subscriber.
67    #[must_use]
68    pub const fn is_delivered(&self) -> bool {
69        self.delivered_count > 0
70    }
71
72    /// The store-assigned append sequence of this publish on a durable
73    /// channel, `None` on a memory channel.
74    ///
75    /// This is the durable position G1 of
76    /// `docs/design/wire-durability-gaps-20260831.md` names: haematite's
77    /// append assigns it, and before this field the caller discarded it, so
78    /// no client of a durable channel could ever learn where its publish
79    /// landed. Any resume/ack protocol needs exactly this value.
80    ///
81    /// Assigned is not flushed: the position is real the moment the append
82    /// returns, but the store flushes on graceful shutdown only (G4's flush
83    /// half), so a crash can still lose the tail. The position tells you
84    /// WHERE the publish sits, not that it survives every failure mode.
85    #[must_use]
86    pub const fn durable_position(&self) -> Option<u64> {
87        self.durable_position
88    }
89
90    /// The A1 backpressure signal for this publish
91    /// (`docs/design/A1-DEFER-SEMANTICS.md` §6: "`ChannelDelivery` gains a
92    /// `pressure()` accessor (additive)").
93    ///
94    /// Orthogonal to [`Self::is_delivered`]. Delivery answers "did a subscriber
95    /// receive it"; pressure answers "should you slow down". A publish to a
96    /// channel with no subscribers is `Accept` and delivered to nobody; a
97    /// publish every consumer buffered is `Defer` and delivered to everybody.
98    #[must_use]
99    pub const fn pressure(&self) -> &PressureSignal {
100        &self.signal
101    }
102
103    /// Whether the bus took custody of the message (A1 §7): `Accept` or
104    /// `Defer`.
105    ///
106    /// The accessor an aion worker's fire-and-forget completion policy reads,
107    /// deliberately distinct from [`Self::is_delivered`] ("a subscriber
108    /// genuinely received it"). A Deferred message is admitted — the bus holds
109    /// it and the consumer's next pop is its redelivery — so a producer that
110    /// re-publishes it with a fresh key double-delivers. `false` means Reject:
111    /// shed, delivered to nobody, and the producer owns the retry.
112    #[must_use]
113    pub const fn is_admitted(&self) -> bool {
114        !matches!(self.signal, PressureSignal::Reject { .. })
115    }
116
117    /// The producer's advisory pacing hint (A1 §3):
118    /// `base + (max − base) × buffer_fill_fraction`, from this channel's
119    /// configured delay policy. `None` on Accept — an accepted publish is not
120    /// paced.
121    ///
122    /// The bus never blocks a producer on this and never lies about it:
123    /// ignoring the hint leads to Reject on an ephemeral channel, and to
124    /// growing hints and eventually the pre-append watermark Reject on a
125    /// durable one.
126    #[must_use]
127    pub fn defer_delay(&self) -> Option<Duration> {
128        defer_delay(&self.signal, self.pressure_config)
129    }
130}
131
132/// Defines whether a channel is memory-only or durable across restarts.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum ChannelMode {
135    /// In-memory channel mode with no persistence overhead.
136    Ephemeral,
137    /// Durable channel mode reserved for future haematite-backed storage.
138    Durable,
139}
140
141/// Compatibility alias for the channel-owned schema definition.
142pub type SchemaRef = Schema;
143
144/// Required configuration for creating a typed channel.
145///
146/// **SEMVER — this type takes a declared break in the next publish wave.** A1
147/// added the `pressure` field, and adding a field to a struct with public
148/// fields breaks every out-of-crate struct-literal construction
149/// (`ChannelConfig { name, schema, mode }` no longer compiles). There is no
150/// version of "carry a per-channel pressure policy on the config" that avoids
151/// it, so it is declared rather than hidden — see
152/// `docs/design/A1-DEFER-WIRING-RECORD-2026-09-01.md` §6.
153///
154/// `#[non_exhaustive]` is added in the SAME wave, deliberately: the break is
155/// already being paid, and it buys the property that no future field costs one
156/// again. Out-of-crate construction goes through [`Self::new`] plus the
157/// `with_*` builders, which is what every construction site in this workspace
158/// already does. Reading a field is unaffected.
159#[derive(Clone, Debug)]
160#[non_exhaustive]
161pub struct ChannelConfig {
162    /// Explicit channel name.
163    pub name: String,
164    /// Explicit schema for validating published payloads.
165    pub schema: Schema,
166    /// Explicit durability mode for the channel.
167    pub mode: ChannelMode,
168    /// A1 pressure policy for this channel: the Defer delay-hint band and the
169    /// durable pre-append hard watermark. Defaulted by [`Self::new`], so no
170    /// existing construction site changes; overridden with
171    /// [`Self::with_pressure`].
172    pub pressure: ChannelPressureConfig,
173}
174
175impl ChannelConfig {
176    /// Creates channel configuration from its required fields, with the
177    /// default A1 pressure policy.
178    #[must_use]
179    pub const fn new(name: String, schema: Schema, mode: ChannelMode) -> Self {
180        Self {
181            name,
182            schema,
183            mode,
184            pressure: ChannelPressureConfig::DEFAULT,
185        }
186    }
187
188    /// Replaces this channel's A1 pressure policy.
189    #[must_use]
190    pub const fn with_pressure(mut self, pressure: ChannelPressureConfig) -> Self {
191        self.pressure = pressure;
192        self
193    }
194}
195
196/// A lazily-spawned, supervised channel actor shared by every clone of a handle.
197///
198/// `supervisor` is stored as a `Result` so [`ChannelHandle::new`] can stay
199/// infallible: a scheduler-start failure is captured here and surfaced as a
200/// `LiminalError` the first time the actor is actually used.
201struct ChannelActorState {
202    supervisor: Result<ChannelSupervisor, String>,
203    core: OnceLock<Result<Arc<ChannelActorCore>, String>>,
204    restarts: AtomicU32,
205}
206
207impl ChannelActorState {
208    const fn new(supervisor: Result<ChannelSupervisor, String>) -> Self {
209        Self {
210            supervisor,
211            core: OnceLock::new(),
212            restarts: AtomicU32::new(0),
213        }
214    }
215
216    fn supervisor(&self) -> Result<&ChannelSupervisor, LiminalError> {
217        self.supervisor
218            .as_ref()
219            .map_err(|message| LiminalError::PublishFailed {
220                message: format!("channel supervisor unavailable: {message}"),
221            })
222    }
223
224    /// The installed cluster observer, if this channel runs on a clustered
225    /// supervisor (SRV-005). Returns `None` for non-clustered channels.
226    fn observer(&self) -> Option<Arc<dyn ClusterObserver>> {
227        self.supervisor
228            .as_ref()
229            .ok()
230            .and_then(|supervisor| supervisor.observer().cloned())
231    }
232
233    /// Returns the live actor core, spawning it (and any restart) on demand.
234    fn core(&self, schema: &Schema) -> Result<Arc<ChannelActorCore>, LiminalError> {
235        let supervisor = self.supervisor()?;
236        let stored = self.core.get_or_init(|| {
237            supervisor
238                .spawn_channel(schema.clone())
239                .map_err(|error| error.to_string())
240        });
241        let core = stored
242            .as_ref()
243            .map_err(|message| LiminalError::PublishFailed {
244                message: format!("channel actor unavailable: {message}"),
245            })?;
246        // Restart on a dead pid (R4) before returning the core for use.
247        supervisor.ensure_running(core, &self.restarts)?;
248        Ok(Arc::clone(core))
249    }
250}
251
252impl std::fmt::Debug for ChannelActorState {
253    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        formatter
255            .debug_struct("ChannelActorState")
256            .field("supervisor", &self.supervisor)
257            .finish_non_exhaustive()
258    }
259}
260
261/// Cloneable handle for interacting with a channel actor process.
262#[derive(Clone, Debug)]
263pub struct ChannelHandle {
264    config: ChannelConfig,
265    actor: Arc<ChannelActorState>,
266    durable: Option<Arc<Mutex<DurableChannel>>>,
267    /// The same store `durable` was built over, kept beside it so A1 §4's
268    /// host-side refill can read the log WITHOUT taking the `DurableChannel`
269    /// mutex the publish path holds. A refill runs on a consumer's thread while
270    /// producers are publishing; making it contend for the append lock would
271    /// let a slow reader stall every writer, which is the opposite of what
272    /// backpressure is for. `Some` exactly when `durable` is.
273    durable_store: Option<Arc<dyn DurableStore>>,
274}
275
276impl ChannelHandle {
277    /// Creates an ephemeral handle backed by a real supervised channel actor on
278    /// the shared default supervisor.
279    ///
280    /// The actor process is spawned lazily on first use; a scheduler failure is
281    /// surfaced as a [`LiminalError`] from the first operation, not a panic.
282    #[must_use]
283    pub fn new(config: ChannelConfig) -> Self {
284        let supervisor = shared_supervisor().map_err(|error| error.to_string());
285        Self {
286            config,
287            actor: Arc::new(ChannelActorState::new(supervisor)),
288            durable: None,
289            durable_store: None,
290        }
291    }
292
293    /// Creates an ephemeral handle bound to an explicit `supervisor` (isolation
294    /// for the registry and tests).
295    #[must_use]
296    pub fn with_supervisor(config: ChannelConfig, supervisor: ChannelSupervisor) -> Self {
297        Self {
298            config,
299            actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
300            durable: None,
301            durable_store: None,
302        }
303    }
304
305    /// Creates a durable handle that persists every accepted publish to `store`
306    /// before fanning it out to subscribers.
307    ///
308    /// Construction reads the store: every partition's next-sequence counter is
309    /// recovered from the existing stream head, so a handle rebuilt over a
310    /// previously used store resumes appending where the log left off instead
311    /// of conflicting at sequence zero. A fresh store recovers to zero, which is
312    /// identical to cold start. This makes construction O(stream length) in
313    /// store reads and adds store read errors to the failure modes below.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
318    /// initialized over `store`, including when recovering the per-partition
319    /// sequence counters from the store fails.
320    pub fn new_durable(
321        config: ChannelConfig,
322        store: Arc<dyn DurableStore>,
323    ) -> Result<Self, LiminalError> {
324        let durable = recover_durable(&config.name, Arc::clone(&store))?;
325        let supervisor = shared_supervisor()?;
326        Ok(Self {
327            config,
328            actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
329            durable: Some(Arc::new(Mutex::new(durable))),
330            durable_store: Some(store),
331        })
332    }
333
334    /// Creates a durable handle bound to an explicit `supervisor`.
335    ///
336    /// Used by the standalone server so every channel — durable or ephemeral —
337    /// shares ONE (optionally clustered) supervisor and thus one scheduler, which
338    /// is the precondition for cross-node delivery (SRV-005): a subscriber pid
339    /// joined to a channel's distributed process group must live on the same
340    /// scheduler that owns the distribution links.
341    ///
342    /// Like [`Self::new_durable`], construction recovers each partition's
343    /// next-sequence counter from the store, so restarting over an existing
344    /// persistence path resumes the log instead of conflicting at sequence zero.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
349    /// initialized over `store`, including when recovering the per-partition
350    /// sequence counters from the store fails.
351    pub fn new_durable_with_supervisor(
352        config: ChannelConfig,
353        store: Arc<dyn DurableStore>,
354        supervisor: ChannelSupervisor,
355    ) -> Result<Self, LiminalError> {
356        let durable = recover_durable(&config.name, Arc::clone(&store))?;
357        Ok(Self {
358            config,
359            actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
360            durable: Some(Arc::new(Mutex::new(durable))),
361            durable_store: Some(store),
362        })
363    }
364
365    /// Returns the channel configuration used to create this handle.
366    #[must_use]
367    pub const fn config(&self) -> &ChannelConfig {
368        &self.config
369    }
370
371    /// Publishes a payload to the channel with the default publisher identity.
372    ///
373    /// # Errors
374    ///
375    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
376    pub fn publish<Payload>(&self, payload: Payload) -> Result<(), LiminalError>
377    where
378        Payload: AsRef<[u8]>,
379    {
380        self.publish_with_context(payload, PublisherId::default(), None)
381    }
382
383    /// Publishes a payload with an explicit publisher identity.
384    ///
385    /// # Errors
386    ///
387    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
388    pub fn publish_from<Payload>(
389        &self,
390        publisher_id: impl Into<PublisherId>,
391        payload: Payload,
392    ) -> Result<(), LiminalError>
393    where
394        Payload: AsRef<[u8]>,
395    {
396        self.publish_with_context(payload, publisher_id.into(), None)
397    }
398
399    /// Publishes a payload with explicit publisher and causal metadata.
400    ///
401    /// # Errors
402    ///
403    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
404    pub fn publish_with_context<Payload>(
405        &self,
406        payload: Payload,
407        publisher_id: PublisherId,
408        causal_context: Option<CausalContext>,
409    ) -> Result<(), LiminalError>
410    where
411        Payload: AsRef<[u8]>,
412    {
413        self.publish_with_delivery(payload, publisher_id, causal_context)
414            .map(|_delivery| ())
415    }
416
417    /// Publishes a payload and reports a genuine delivery ack.
418    ///
419    /// Returns a [`ChannelDelivery`] whose `delivered_count` is the number of
420    /// local subscribers the message was actually delivered to. A caller that
421    /// needs to know the message was ACCEPTED by a subscriber (not merely
422    /// buffered/published) inspects [`ChannelDelivery::is_delivered`]. This is the
423    /// channel-library half of the 13-L1 delivery-ack signal; the publish-without-
424    /// delivery methods stay unchanged for existing callers.
425    ///
426    /// # Errors
427    ///
428    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
429    pub fn publish_with_delivery<Payload>(
430        &self,
431        payload: Payload,
432        publisher_id: PublisherId,
433        causal_context: Option<CausalContext>,
434    ) -> Result<ChannelDelivery, LiminalError>
435    where
436        Payload: AsRef<[u8]>,
437    {
438        let core = self.core()?;
439        // G4 (docs/design/wire-durability-gaps-20260831.md): the schema/closed
440        // gate runs BEFORE the durable bridge, so a payload the channel would
441        // refuse never burns a store sequence. The actor's validation inside
442        // `publish` below stays authoritative; a concurrent schema evolve
443        // narrows the remaining pre-validated-then-refused window to that race.
444        core.pre_publish_gate(payload.as_ref())?;
445        // A1 §4 (graft §0.3), the PRE-APPEND HARD WATERMARK. A durable channel
446        // owes the producer Defer-not-Reject after an append, which means the
447        // only honest Reject it can ever emit is one taken before anything is
448        // persisted. Without this check a producer that ignores delay hints
449        // grows the log without bound, because every post-append outcome is by
450        // construction an admission.
451        //
452        // Cheap and coarse on purpose: a channel-aggregate occupancy read with
453        // no predicate evaluation, so it may refuse a message whose target
454        // subscriber is fast. The design documents that imprecision and defers
455        // the precise per-cursor bound to v2, once live consumer cursors exist.
456        if let Some(rejection) = self.watermark_rejection(&core)? {
457            return Ok(rejection);
458        }
459        // Durable channels persist the message to the store BEFORE acknowledging
460        // the publish (and before fanning out): a published message that was not
461        // durably recorded would be lost on shutdown, which CN7 forbids. (The
462        // store is flushed on graceful shutdown only — an unflushed append can
463        // still be lost to a crash; that bound is G4's flush half, named in the
464        // gaps docket, not silently absorbed here.)
465        //
466        // AND THE FAN-OUT IS ENQUEUED UNDER THE SAME LOCK (round 2). See
467        // `persist_and_enqueue` for why: the position and the queue slot are
468        // taken together, so the actor's FIFO command queue receives durable
469        // publishes in durable-position order. Everything §4 does with the
470        // replay cursor — a single `u64` meaning "the contiguous prefix this
471        // subscriber has been offered" — is false without it.
472        let (durable_position, pending) = match self.durable.as_ref() {
473            Some(durable) => self.persist_and_enqueue(
474                &core,
475                durable,
476                payload.as_ref(),
477                publisher_id,
478                causal_context,
479            )?,
480            None => (
481                None,
482                core.enqueue_publish(
483                    payload.as_ref().to_vec(),
484                    publisher_id,
485                    causal_context,
486                    None,
487                )?,
488            ),
489        };
490        // The wait happens with the append lock released: the ordering is
491        // already fixed by the enqueue above, and holding it across the actor
492        // rendezvous would put a slow subscriber's notifier in front of the
493        // next publisher's append for no ordering gain at all.
494        let outcome = core.await_publish(pending)?;
495        // SRV-005: hand the normalised envelope to the cluster observer so it can
496        // fan the message out to remote subscribers. Local fan-out already
497        // happened inside `core.publish`; this is purely the cross-node leg and
498        // is a no-op when no observer is installed (non-clustered channels).
499        if let Some(observer) = self.actor.observer() {
500            observer.on_publish(&self.config.name, &outcome.envelope);
501        }
502        // A1 §4: "after append, durable channels Defer, never Reject." The
503        // message is at its sequence position and every replay consumer sees it
504        // exactly once regardless of what the live buffers did — the live shed
505        // marked its subscriber lagging and auto-catch-up covers the gap.
506        // Emitting Reject here would say "delivered to nobody, retry", which is
507        // false of a message that is in the log AND on its way to a consumer.
508        //
509        // WITH ONE EXCEPTION, AND IT IS A MEASURED ONE (round 3). That
510        // justification holds for the A1 §4 PACING shed, which arms
511        // auto-catch-up (`admit_inner` sets `state.lagging` on a durable
512        // Reject). It is FALSE for the two §5 memory-safety doors: neither
513        // `FairnessTripped` nor `BudgetExceeded` sets `lagging` or bumps
514        // `shed_generation`, so no refill is ever due, and on the connection
515        // path the subscription is torn down outright before one could run —
516        // the server's delivery pump sheds an `is_overflowed()` subscription
517        // with a typed `SubscribeError` and releases it, and `try_next`, the
518        // ONLY driver of `refill_if_lagging`, is never called on it again.
519        // MEASURED, M1, gate-logs/a1-defer/m1-measurement-section5-recovery.log:
520        // after the shedding pressure was fully freed and the consumer polled
521        // on, the pacing door recovered 3/3 of its shed rows and the two §5
522        // doors recovered 0/4 and 0/13.
523        //
524        // So when EVERY matching subscriber dropped the envelope through §5,
525        // the rewrite is suppressed and the producer reads the Reject the
526        // aggregate actually resolved. That answer is about LIVE CUSTODY, not
527        // about the log: the row IS appended and a deliberate `replay_from`
528        // read still finds it, but no subscription will ever be offered it
529        // again (a fresh subscription seeds its replay cursor at the CURRENT
530        // head), so telling the producer "admitted" would be the §7 custody lie
531        // F2 closed on ephemeral channels, arriving by a different door.
532        //
533        // A §5 drop BESIDE a subscriber that took it, or beside one the pacing
534        // door shed, is not this case: `every_match_dropped` is false and the
535        // Defer stands, because a consumer will still get the message.
536        //
537        // SENTINEL-SHAPES: this makes the two channel modes give the same shape
538        // of answer to the same situation — an everybody-dropped publish is a
539        // Reject on ephemeral and on durable alike.
540        let signal = if durable_position.is_some() && !outcome.every_match_dropped {
541            defer_after_append(outcome.signal)
542        } else {
543            outcome.signal
544        };
545        Ok(ChannelDelivery {
546            delivered_count: outcome.delivered_count,
547            durable_position,
548            signal,
549            pressure_config: self.config.pressure,
550        })
551    }
552
553    /// The pre-append hard watermark verdict for a durable channel (A1 §4).
554    ///
555    /// `Some(delivery)` is an honest Reject: **nothing was appended**, nothing
556    /// was fanned out, `durable_position` is `None`, and `delivered_count` is
557    /// zero — so the caller's `Reject ⇒ delivered to nobody` reading holds and
558    /// the server's dedup mapping releases the claim exactly as if the publish
559    /// had never happened (§5). `None` means proceed.
560    ///
561    /// Always `None` on a memory channel: there is no log to bound, and an
562    /// ephemeral channel's Reject is the per-message shed the fan-out produces.
563    fn watermark_rejection(
564        &self,
565        core: &Arc<ChannelActorCore>,
566    ) -> Result<Option<ChannelDelivery>, LiminalError> {
567        if self.durable.is_none() {
568            return Ok(None);
569        }
570        let (total_queued, total_bound) = core.live_buffer_occupancy()?;
571        if !watermark_reached(
572            total_queued,
573            total_bound,
574            self.config.pressure.durable_reject_watermark_percent,
575        ) {
576            return Ok(None);
577        }
578        Ok(Some(ChannelDelivery {
579            delivered_count: 0,
580            durable_position: None,
581            signal: PressureSignal::reject(total_queued, total_bound, total_queued, total_bound),
582            pressure_config: self.config.pressure,
583        }))
584    }
585
586    /// The durable partition stream key and its current head — the point a new
587    /// subscription joins the log at (A1 §4).
588    ///
589    /// Liminal backs a runtime channel with a single partition
590    /// ([`RUNTIME_DURABLE_PARTITIONS`]), so partition 0 is the whole log.
591    fn durable_join_point(
592        durable: &Arc<Mutex<DurableChannel>>,
593    ) -> Result<(String, u64), LiminalError> {
594        let channel = durable
595            .lock()
596            .map_err(|error| LiminalError::PublishFailed {
597                message: format!("durable channel state unavailable: {error}"),
598            })?;
599        let stream_key = channel.stream_key_for(0);
600        let head = channel.next_expected_sequence(0).unwrap_or(0);
601        drop(channel);
602        Ok((stream_key, head))
603    }
604
605    /// The store this durable channel was built over.
606    fn durable_store(&self) -> Result<Arc<dyn DurableStore>, LiminalError> {
607        self.durable_store
608            .as_ref()
609            .map(Arc::clone)
610            .ok_or_else(|| LiminalError::PublishFailed {
611                message: format!(
612                    "channel '{}' has no durable store to replay from",
613                    self.config.name
614                ),
615            })
616    }
617
618    /// Persists one publish to the durable store and hands its fan-out to the
619    /// actor's command queue **without releasing the append lock in between**
620    /// (A1 §4; round-2 finding). Returns the assigned append sequence — the
621    /// durable position G1 says must not be discarded — and the pending
622    /// rendezvous for the fan-out.
623    ///
624    /// **Why the two must not be separated.** `DurableChannel::publish` assigns
625    /// sequences under this mutex, so durable positions are totally ordered.
626    /// The actor's command queue is a `VecDeque` drained in push order, so the
627    /// fan-out order is the enqueue order. Take the position under the lock and
628    /// enqueue after releasing it, and the two orders are independent: two
629    /// publishers can invert, the higher position's live push reaching an inbox
630    /// first. That is not a cosmetic reordering. §4's replay cursor is one
631    /// `u64` meaning "the contiguous prefix this subscriber has been offered",
632    /// and an inversion makes that sentence false — the cursor steps past a
633    /// message that has not been offered, and backsteps onto one that has.
634    /// Enqueuing here, inside the same critical section, is what makes the two
635    /// orders the same order.
636    ///
637    /// **What it costs.** The section grows by one `VecDeque` push under the
638    /// command mutex plus one non-blocking scheduler wake — both O(1), neither
639    /// blocking. The fan-out ITSELF is not held here (that is
640    /// [`ChannelActorCore::await_publish`], called with this lock released), so
641    /// the next publisher's append still overlaps this publisher's fan-out
642    /// exactly as it did before: the pipeline is unchanged and no subscriber's
643    /// notifier can ever stall an append.
644    fn persist_and_enqueue(
645        &self,
646        core: &Arc<ChannelActorCore>,
647        durable: &Arc<Mutex<DurableChannel>>,
648        payload: &[u8],
649        publisher_id: PublisherId,
650        causal_context: Option<CausalContext>,
651    ) -> Result<(Option<u64>, PendingPublish), LiminalError> {
652        let envelope = MessageEnvelope {
653            payload: payload.to_vec(),
654            causal_context: None,
655            timestamp: now_millis(),
656            publisher_id: publisher_id.as_str().to_owned(),
657            idempotency_key: None,
658        };
659        let mut channel = durable
660            .lock()
661            .map_err(|error| LiminalError::PublishFailed {
662                message: format!("durable channel state unavailable: {error}"),
663            })?;
664        let assigned_seq = block_on(channel.publish(&envelope))
665            .map_err(|error| LiminalError::PublishFailed {
666                message: format!(
667                    "durable publish bridge for channel '{}' failed: {error}",
668                    self.config.name
669                ),
670            })?
671            .map_err(|error| LiminalError::PublishFailed {
672                message: format!(
673                    "durable publish to channel '{}' failed: {error}",
674                    self.config.name
675                ),
676            })?;
677        let pending = core.enqueue_publish(
678            payload.to_vec(),
679            publisher_id,
680            causal_context,
681            Some(assigned_seq),
682        )?;
683        drop(channel);
684        Ok((Some(assigned_seq), pending))
685    }
686
687    /// Returns the schema version currently owned by the channel actor.
688    ///
689    /// # Errors
690    ///
691    /// Returns a [`LiminalError`] when the channel actor cannot be read.
692    pub fn current_schema_id(&self) -> Result<SchemaId, LiminalError> {
693        self.core()?.schema_id()
694    }
695
696    /// Evolves the channel schema by adding a defaulted field without disconnecting subscribers.
697    ///
698    /// # Errors
699    ///
700    /// Returns [`SchemaValidationError`] when the schema cannot be evolved.
701    pub fn evolve_schema_add_field(
702        &self,
703        name: impl Into<String>,
704        field_schema: Value,
705        default: Value,
706    ) -> Result<SchemaId, SchemaValidationError> {
707        let core = self
708            .core()
709            .map_err(|error| SchemaValidationError::InvalidSchema {
710                message: error.to_string(),
711            })?;
712        core.evolve(name.into(), field_schema, default)
713    }
714
715    /// Subscribes to the channel, receiving every published message.
716    ///
717    /// # Errors
718    ///
719    /// Returns a [`LiminalError`] when a subscription cannot be created.
720    pub fn subscribe(&self) -> Result<SubscriptionHandle, LiminalError> {
721        self.subscribe_inner(None, None)
722    }
723
724    /// Subscribes with a server-connection [`InboxInstall`]: the §5 shared byte
725    /// budget, per-inbox fairness cap, and R3 wake notifier are installed on the
726    /// inbox AT CONSTRUCTION — strictly before the registration is published to
727    /// the channel actor — so no envelope can be admitted uncharged, past the
728    /// depth cap, or without a wake (the pre-install window is structurally
729    /// closed, not merely narrowed).
730    ///
731    /// # Errors
732    ///
733    /// Returns a [`LiminalError`] when a subscription cannot be created.
734    pub fn subscribe_with_install(
735        &self,
736        install: InboxInstall,
737    ) -> Result<SubscriptionHandle, LiminalError> {
738        self.subscribe_inner(None, Some(install))
739    }
740
741    /// Subscribes with a delivery predicate: only messages for which `predicate`
742    /// returns `true` are delivered to this subscriber. The predicate is owned
743    /// and evaluated by the actor process (R3).
744    ///
745    /// # Clustering
746    ///
747    /// The predicate filters **local-node publishes only**. Under clustering
748    /// (SRV-005), messages published on a remote node are delivered to this
749    /// subscriber *ungated* — the predicate is a non-serializable closure and is
750    /// not propagated across the wire, so remote nodes cannot evaluate it. If you
751    /// need filtering to hold for cross-node traffic, filter again on receipt
752    /// rather than relying on this predicate alone.
753    ///
754    /// # Errors
755    ///
756    /// Returns a [`LiminalError`] when a subscription cannot be created.
757    pub fn subscribe_filtered<F>(&self, predicate: F) -> Result<SubscriptionHandle, LiminalError>
758    where
759        F: Fn(&Envelope) -> bool + Send + Sync + 'static,
760    {
761        self.subscribe_inner(Some(predicate_from(predicate)), None)
762    }
763
764    fn subscribe_inner(
765        &self,
766        predicate: Option<SubscriptionPredicate>,
767        install: Option<InboxInstall>,
768    ) -> Result<SubscriptionHandle, LiminalError> {
769        let core = self.core()?;
770        let (handle, registration) =
771            SubscriptionHandle::spawn(core.scheduler(), predicate.clone(), install)?;
772        // A1 §4 auto-catch-up: attach the durable log this subscription can
773        // converge from, and seed its replay cursor at the head it is JOINING
774        // AT — both strictly before the registration reaches the actor, so the
775        // first publish this subscription can possibly see is measured against
776        // the right origin. A cursor left at zero would replay the entire
777        // history on the first shed.
778        if let Some(durable) = self.durable.as_ref() {
779            let (stream_key, head) = Self::durable_join_point(durable)?;
780            handle.seed_replay_cursor(head);
781            handle.attach_durable_refill(
782                self.durable_store()?,
783                stream_key,
784                self.config.schema.id(),
785                predicate,
786            );
787        }
788        let pid = registration.pid();
789        core.subscribe(registration)?;
790        // SRV-005: tell the cluster a local subscriber joined this channel so it
791        // can advertise the subscription to peers via its process group.
792        if let Some(observer) = self.actor.observer() {
793            observer.on_subscribe(&self.config.name, pid);
794        }
795        Ok(handle)
796    }
797
798    /// Unsubscribes the subscriber owning `subscription` by its process pid.
799    ///
800    /// # Errors
801    ///
802    /// Returns a [`LiminalError`] when the unsubscribe command fails.
803    pub fn unsubscribe(&self, subscription: &SubscriptionHandle) -> Result<(), LiminalError> {
804        let pid = subscription.pid();
805        self.core()?.unsubscribe(pid)?;
806        // SRV-005: tell the cluster the local subscriber left so it can withdraw
807        // the subscription from its process group.
808        if let Some(observer) = self.actor.observer() {
809            observer.on_unsubscribe(&self.config.name, pid);
810        }
811        Ok(())
812    }
813
814    /// Flushes buffered durable channel state to the backing store before shutdown.
815    ///
816    /// # Errors
817    ///
818    /// Returns a [`LiminalError`] when the channel actor cannot be inspected or
819    /// when the durable store flush fails.
820    pub fn flush(&self) -> Result<(), LiminalError> {
821        // Confirm the actor is reachable (and restart it if needed) before flush.
822        drop(self.core()?);
823        let Some(durable) = self.durable.as_ref() else {
824            return Ok(());
825        };
826        let flush_result = {
827            let channel = durable
828                .lock()
829                .map_err(|error| LiminalError::PublishFailed {
830                    message: format!("durable channel state unavailable: {error}"),
831                })?;
832            block_on(channel.flush_store())
833        };
834        flush_result
835            .map_err(|error| LiminalError::PublishFailed {
836                message: format!(
837                    "durable flush bridge for channel '{}' failed: {error}",
838                    self.config.name
839                ),
840            })?
841            .map_err(|error| LiminalError::PublishFailed {
842                message: format!(
843                    "durable flush for channel '{}' failed: {error}",
844                    self.config.name
845                ),
846            })?;
847        Ok(())
848    }
849
850    /// Returns the number of currently-active subscribers on the channel actor.
851    ///
852    /// # Errors
853    ///
854    /// Returns a [`LiminalError`] when the actor cannot service the query.
855    pub fn subscriber_count(&self) -> Result<usize, LiminalError> {
856        Ok(self.core()?.list_subscribers()?.len())
857    }
858
859    /// Closes the channel gracefully, stopping the actor process.
860    ///
861    /// # Errors
862    ///
863    /// Returns a [`LiminalError`] when the channel cannot be shut down.
864    pub fn close(&self) -> Result<(), LiminalError> {
865        self.core()?.close()
866    }
867
868    /// Whether this channel's actor has been spawned — **without spawning it**.
869    ///
870    /// This is the one accessor on the handle that observes the lazy-spawn
871    /// state instead of consuming it. Every other accessor
872    /// ([`subscriber_count`](Self::subscriber_count),
873    /// [`close`](Self::close)) routes through the private `core()`, which
874    /// spawns the actor on demand, so reading "is there an actor?" through any
875    /// of them destroys the very property being read. A handle that spawns
876    /// lazily has a genuine, observable idle/live state, and a consumer that
877    /// needs to know it — a registry reporting which of its channels are inert,
878    /// a cost accountant bounding what mere configuration costs — otherwise has
879    /// to choose between not knowing and changing the answer by asking.
880    ///
881    /// It reads the memoised spawn slot and nothing else: no supervisor call,
882    /// no schema clone, no `ensure_running`. That last exclusion is the point
883    /// of the restriction — `ensure_running` RESTARTS a dead actor, so an
884    /// implementation routed through it would repair the state rather than
885    /// report it.
886    ///
887    /// # What `false` and `true` mean
888    ///
889    /// The slot holds `Result<Arc<ChannelActorCore>, String>`: a spawn that
890    /// failed is memoised as `Err` and, because the slot is one-shot, is never
891    /// retried — every later operation on this handle fails with that stored
892    /// message. So a failed spawn ATTEMPT leaves no actor and can never acquire
893    /// one, and this method answers `false` for it, exactly as it does for a
894    /// channel nobody has touched. `false` therefore means "no actor core
895    /// exists" and deliberately does not distinguish "never attempted" from
896    /// "attempted and permanently failed" — neither has an actor.
897    ///
898    /// `true` means the actor core exists, not that its process is currently
899    /// alive: an actor whose pid has died stays `true` until the next real
900    /// operation restarts it through the supervisor. Liveness is a stronger
901    /// question than this method asks, and answering it would require running
902    /// the repair path this method exists to avoid.
903    #[must_use]
904    pub fn is_actor_spawned(&self) -> bool {
905        self.actor.core.get().is_some_and(Result::is_ok)
906    }
907
908    fn core(&self) -> Result<Arc<ChannelActorCore>, LiminalError> {
909        self.actor.core(&self.config.schema)
910    }
911
912    /// The channel actor's current beamr pid, ensuring it is running first.
913    /// Test-only: lets restart tests crash the exact actor process.
914    #[cfg(test)]
915    pub(crate) fn actor_pid(&self) -> Result<u64, LiminalError> {
916        let core = self.core()?;
917        core.current_pid()?
918            .ok_or_else(|| LiminalError::DeliveryFailed {
919                message: "channel actor has no live pid".to_owned(),
920            })
921    }
922
923    /// The scheduler the channel actor and its subscribers run on (test-only).
924    #[cfg(test)]
925    pub(crate) fn scheduler(&self) -> Result<Arc<beamr::scheduler::Scheduler>, LiminalError> {
926        Ok(Arc::clone(self.core()?.scheduler()))
927    }
928}
929
930/// Reconstructs a durable channel over `store`, deriving each partition's next
931/// sequence from the persisted log so a restart over an existing stream appends at
932/// the tail rather than colliding with the occupied head (H2 / ledger G1). On a
933/// fresh store, recovery yields zeroed sequences — identical to a first-boot
934/// channel — so this is the single durable-construction path for both cold start
935/// and restart.
936///
937/// The async recovery is driven to completion through the synchronous
938/// [`block_on`] bridge, exactly as the durable publish/flush paths do: the
939/// haematite store completes on its first poll, so no executor is needed.
940fn recover_durable(
941    channel_name: &str,
942    store: Arc<dyn DurableStore>,
943) -> Result<DurableChannel, LiminalError> {
944    block_on(recover_durable_channel(
945        channel_name.to_owned(),
946        RUNTIME_DURABLE_PARTITIONS,
947        store,
948    ))
949    .map_err(|error| LiminalError::PublishFailed {
950        message: format!("durable recovery bridge for channel '{channel_name}' failed: {error}"),
951    })?
952    .map_err(|error| LiminalError::PublishFailed {
953        message: format!("failed to recover durable channel '{channel_name}': {error}"),
954    })
955}
956
957/// Returns the current epoch milliseconds, saturating to zero before the epoch.
958fn now_millis() -> u64 {
959    SystemTime::now()
960        .duration_since(UNIX_EPOCH)
961        .map_or(0, |duration| {
962            u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
963        })
964}