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