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