Skip to main content

liminal_server/server/connection/
channel_registry.rs

1//! Runtime channel registration vocabulary: the roster's value types and its two
2//! error taxonomies.
3//!
4//! The registration APIs themselves are inherent methods on
5//! [`super::services::LiminalConnectionServices`] — the one adapter that owns a
6//! channel roster — and they live beside the roster because they need its
7//! private state. This module owns everything a CALLER of those methods names:
8//! the request, the outcomes, the probe's answers, and the two error enums.
9//!
10//! # Why two error enums
11//!
12//! They have disjoint call sites and only one of them reaches the wire.
13//! [`ChannelRegistryError`] answers the embedding host holding the services
14//! handle: it is a control-plane refusal, never rendered into a frame.
15//! [`ChannelAccessError`] answers a frame: it is produced by the roster
16//! admission funnel on the publish/subscribe path and carries its own wire
17//! reason code.
18//!
19//! # No `#[non_exhaustive]`
20//!
21//! Deliberate, and ruled on the record: these enums are exhaustive so a consumer
22//! can `match` them and have the compiler tell it when this lane adds a case.
23//! The cost — a new variant is a breaking change — is the price of that signal,
24//! and it is priced at the cut rather than avoided by leaving every consumer a
25//! catch-all arm it can never reason about.
26
27use liminal::channel::ChannelMode;
28use liminal::protocol::SchemaId as ProtocolSchemaId;
29use liminal_protocol::reason_code::{
30    CHANNEL_NOT_REGISTERED_CODE, CHANNEL_QUIESCED_CODE, CONSOLE_NAMESPACE_REFUSED_CODE,
31    CONSOLE_TAP_LIMIT_REFUSED_CODE, CONSOLE_TAP_TARGET_REFUSED_CODE,
32};
33
34#[cfg(test)]
35#[path = "channel_registry_tests.rs"]
36mod channel_registry_tests;
37
38/// The undifferentiated server error code, for the one [`ChannelAccessError`]
39/// that is not a statement about the roster.
40///
41/// # Why this value is re-declared here rather than imported
42///
43/// `SERVER_ERROR_CODE` has no single owning definition in this crate: it is
44/// minted privately, at the same value, in each module that needs it
45/// (`apply.rs`, `pending_reply.rs`, `delivery.rs`). Each of those is a private
46/// module-level `const`, reachable from nowhere else, so there is nothing to
47/// import; this module is the fourth site and follows the same shape. The
48/// authority the value is checked against is the band map in
49/// `liminal_protocol::reason_code`, which records `0xFFFF` and enumerates every
50/// site that mints it — this one included. Hoisting the four into one shared
51/// definition is a worthwhile cleanup and is NOT this lane's: it would edit
52/// `apply.rs`, which this step is fenced out of.
53const SERVER_ERROR_CODE: u16 = 0xFFFF;
54
55/// The namespace the server publishes its console records under
56/// (`console.topology`, `console.tap.<target>`).
57pub const CONSOLE_NAMESPACE_PREFIX: &str = "console.";
58
59/// Whether `subject` is in the server's console namespace.
60///
61/// The one test the admission guard, the tap hook and the cluster fan-out all ask, so they can
62/// never disagree about which subjects are the server's own.
63#[must_use]
64pub fn is_console_subject(subject: &str) -> bool {
65    subject.starts_with(CONSOLE_NAMESPACE_PREFIX)
66}
67
68/// The prefix of a tap's subject; the rest names the node or stream tapped.
69pub const CONSOLE_TAP_PREFIX: &str = "console.tap.";
70
71/// The target of a `console.tap.<target>` subject, or `None` for any other
72/// subject. `console.tap.` alone has the empty target.
73#[must_use]
74pub fn console_tap_target(subject: &str) -> Option<&str> {
75    subject.strip_prefix(CONSOLE_TAP_PREFIX)
76}
77
78/// A channel to register at runtime.
79///
80/// Mirrors exactly what the boot loop consumes from a configured channel, minus
81/// the config-file concerns: there is no `schema_ref` here because a path
82/// resolved relative to a config file is not a runtime input.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct ChannelRegistration {
85    /// Channel name — the roster key.
86    pub name: String,
87    /// Raw JSON Schema bytes. `None` means the permissive empty schema `{}`,
88    /// identical to a boot channel that declared no `schema_ref`. The protocol
89    /// schema id is derived from THESE bytes by the same derivation the boot
90    /// path uses, so an SDK deriving ids from schema bytes converges on it
91    /// exactly as it does for a boot channel.
92    pub schema_bytes: Option<Vec<u8>>,
93    /// Durable vs ephemeral — the same bit a configured channel's `durable`
94    /// carries, selecting the same [`ChannelMode`] and the same constructor.
95    pub durable: bool,
96}
97
98/// Whether a registration created the channel or found it already identical.
99///
100/// Distinguishable on purpose: a projector's truth stream reports which one it
101/// saw, and "already identical" is the answer that makes re-projection safe.
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub enum Registered {
104    /// The channel was not on the roster and now is.
105    Created,
106    /// The channel was already on the roster with an identical configuration;
107    /// nothing was built and nothing changed.
108    AlreadyIdentical,
109}
110
111/// Where a roster entry came from.
112///
113/// Behaviourally inert — a boot-configured and a runtime-registered channel are
114/// the same kind of object, built by the same function, on the same supervisor,
115/// over the same store. The tag exists because it is the only field that
116/// predicts what a process restart does: a `BootConfigured` entry is rebuilt
117/// from the config file, a `RuntimeRegistered` entry is simply absent. It never
118/// flips, so the population it names stays well defined over time.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum ChannelOrigin {
121    /// Built by the boot loop from the operator's `[[channels]]` config.
122    BootConfigured,
123    /// Registered at runtime through the embedded registration API.
124    RuntimeRegistered,
125}
126
127/// The state machine's two states, as a value.
128///
129/// `Quiesced` is terminal within a process lifetime: there is no un-quiesce and
130/// no removal, so the only exit is a restart, which drops the runtime roster
131/// entirely.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub enum ChannelState {
134    /// New publishes and new subscribes are admitted.
135    Active,
136    /// New publishes and new subscribes are refused, carrying `reason`;
137    /// subscribers that already hold a stream keep it.
138    Quiesced {
139        /// The operator-supplied cause, recorded once at the transition.
140        reason: String,
141    },
142}
143
144/// The probe's typed answer.
145///
146/// A carrier-waits protocol keys its backoff on this and nothing else: each
147/// answer is a distinct constructor, so a consumer reports which one it saw
148/// without a string in sight.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub enum ChannelStatus {
151    /// No entry of this name is on the roster.
152    NotRegistered,
153    /// The channel is on the roster and admitting.
154    Active {
155        /// Where the entry came from, and therefore what a restart does to it.
156        origin: ChannelOrigin,
157        /// Durable or ephemeral, read back off the live entry.
158        mode: ChannelMode,
159        /// The protocol schema id advertised to subscribers.
160        schema: ProtocolSchemaId,
161    },
162    /// The channel is on the roster and refusing new access.
163    Quiesced {
164        /// The cause recorded at the transition.
165        reason: String,
166        /// Where the entry came from, and therefore what a restart does to it.
167        origin: ChannelOrigin,
168        /// Durable or ephemeral, read back off the live entry.
169        mode: ChannelMode,
170    },
171}
172
173/// One roster entry, minimally.
174///
175/// Deliberately NOT the full status: an enumeration is a census instrument, and
176/// a census needs names, origins, and states — not schemas. A by-name probe can
177/// confirm that every EXPECTED name is present but can never detect an
178/// unexpected extra, which is why the enumerator exists at all.
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct ChannelDescriptor {
181    /// The roster key.
182    pub name: String,
183    /// Where the entry came from.
184    pub origin: ChannelOrigin,
185    /// The entry's state at the moment of the read.
186    pub state: ChannelState,
187}
188
189/// The fields compared for configuration identity.
190///
191/// Named by type rather than by string so a consumer branches on the field that
192/// differed instead of parsing a message for it.
193#[derive(Clone, Copy, Debug, PartialEq, Eq)]
194pub enum ChannelConfigField {
195    /// Durable vs ephemeral.
196    Mode,
197    /// The protocol schema id advertised to subscribers. Derived from the RAW
198    /// schema bytes, so two byte sequences that parse to the same document but
199    /// differ in whitespace differ HERE — and must, because the id is on the
200    /// wire.
201    SchemaId,
202    /// The parsed JSON Schema document. Compared as well as the id because the
203    /// id is a 64-bit non-cryptographic digest: without this, a digest
204    /// collision would silently accept a different schema as identical.
205    SchemaDocument,
206}
207
208impl std::fmt::Display for ChannelConfigField {
209    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            Self::Mode => formatter.write_str("mode"),
212            Self::SchemaId => formatter.write_str("schema id"),
213            Self::SchemaDocument => formatter.write_str("schema document"),
214        }
215    }
216}
217
218/// The config key whose value bounds runtime registration.
219///
220/// Carried on both cap refusals so an operator is told what to declare rather
221/// than merely that something is missing.
222pub const MAX_CHANNELS_KEY: &str = "limits.max_channels";
223
224/// Failures of the registration APIs. Never reaches the wire.
225#[derive(Debug, thiserror::Error)]
226pub enum ChannelRegistryError {
227    /// The name exists with a DIFFERENT configuration. `field` names the first
228    /// field that differs, by type — never a string a consumer must parse.
229    #[error("channel '{name}' is already registered with a different {field}")]
230    AlreadyRegistered {
231        /// The contested roster key.
232        name: String,
233        /// The first field of the compared set that did not match.
234        field: ChannelConfigField,
235    },
236
237    /// Quiesce or probe named a channel that is not on the roster.
238    #[error("channel '{name}' is not registered")]
239    NotRegistered {
240        /// The absent roster key.
241        name: String,
242    },
243
244    /// Re-quiesce under a DIFFERENT reason. Quiesce is one-way and its reason is
245    /// written once; a second reason would silently lose one of the two.
246    #[error("channel '{name}' is already quiesced: {reason}")]
247    AlreadyQuiesced {
248        /// The already-quiesced roster key.
249        name: String,
250        /// The reason already on record — not the one that was just refused.
251        reason: String,
252    },
253
254    /// The schema bytes did not parse as JSON, or did not compile as a JSON
255    /// Schema.
256    #[error("channel '{name}' schema rejected: {message}")]
257    SchemaRejected {
258        /// The channel whose schema was refused.
259        name: String,
260        /// The parser's or the compiler's own diagnostic.
261        message: String,
262    },
263
264    /// Durable initialization over the shared store failed.
265    #[error("durable channel '{name}' could not be initialized: {message}")]
266    DurableInitFailed {
267        /// The channel whose durable construction failed.
268        name: String,
269        /// The library's own diagnostic.
270        message: String,
271    },
272
273    /// Runtime registration was attempted with no cap declared. Refused rather
274    /// than admitted: unbounded-by-default is not a bound.
275    #[error(
276        "runtime channel registration refused: no {cap} is configured; \
277         a deployment that registers channels at runtime must declare its bound"
278    )]
279    CapNotConfigured {
280        /// The config key the operator must declare.
281        cap: &'static str,
282    },
283
284    /// The declared cap is already reached.
285    #[error("channel registration refused: the {cap} limit of {limit} is reached")]
286    CapReached {
287        /// The config key that declared the bound.
288        cap: &'static str,
289        /// The configured value, so the refusal states the bound it enforced.
290        limit: usize,
291    },
292
293    /// The roster lock is poisoned.
294    #[error("channel roster unavailable: {message}")]
295    RosterUnavailable {
296        /// The diagnostic for the unavailable roster.
297        message: String,
298    },
299}
300
301/// The hot-path admission refusal.
302///
303/// Produced by the roster admission funnel and rendered to the wire with a
304/// reason code. Distinct from [`ChannelRegistryError`] because this one crosses
305/// to a client: every variant here has to answer [`Self::reason_code`].
306#[derive(Debug, thiserror::Error)]
307pub enum ChannelAccessError {
308    /// No entry of this name is on the roster.
309    #[error("channel '{name}' is not registered")]
310    NotRegistered {
311        /// The refused channel name.
312        name: String,
313    },
314
315    /// The entry is on the roster but quiesced.
316    #[error("channel '{name}' is quiesced: {reason}")]
317    Quiesced {
318        /// The refused channel name.
319        name: String,
320        /// The cause recorded at the transition, carried to the caller.
321        reason: String,
322    },
323
324    /// The roster lock is poisoned, so no admission decision can be made.
325    #[error("channel roster unavailable: {message}")]
326    RosterUnavailable {
327        /// The diagnostic for the unavailable roster.
328        message: String,
329    },
330
331    /// A connection asked to publish under `console.`. Only the server writes
332    /// there, so a console reading those subjects is never shown a client's
333    /// record as the server's. Subscribing stays open: the console is itself
334    /// a connection that reads them.
335    #[error(
336        "channel '{name}' is in the server's console namespace; \
337         a connection may not publish to it"
338    )]
339    ConsoleNamespace {
340        /// The refused channel name.
341        name: String,
342    },
343
344    /// A subscribe to `console.tap.<target>` named no target, or a target
345    /// under `console.`: the tap never taps the console's own records.
346    #[error("tap target of '{name}' is refused: a tap needs a target outside console.")]
347    ConsoleTapTarget {
348        /// The refused channel name.
349        name: String,
350    },
351
352    /// A subscribe would start one more live tap than the server's bound.
353    #[error("tap '{name}' is refused: {limit} taps are live, the most the server runs")]
354    ConsoleTapLimit {
355        /// The refused channel name.
356        name: String,
357        /// The bound on live taps.
358        limit: usize,
359    },
360}
361
362impl ChannelAccessError {
363    /// The stable wire reason code for this refusal.
364    ///
365    /// The two roster codes are minted in `liminal-protocol` so an SDK client —
366    /// which depends on that crate and not on this one — names them instead of
367    /// hardcoding a literal. [`Self::RosterUnavailable`] keeps the
368    /// undifferentiated server code: it is an internal fault, not a statement
369    /// about the channel, and claiming otherwise would be a typed lie.
370    #[must_use]
371    pub const fn reason_code(&self) -> u16 {
372        match self {
373            Self::NotRegistered { .. } => CHANNEL_NOT_REGISTERED_CODE,
374            Self::Quiesced { .. } => CHANNEL_QUIESCED_CODE,
375            Self::RosterUnavailable { .. } => SERVER_ERROR_CODE,
376            Self::ConsoleNamespace { .. } => CONSOLE_NAMESPACE_REFUSED_CODE,
377            Self::ConsoleTapTarget { .. } => CONSOLE_TAP_TARGET_REFUSED_CODE,
378            Self::ConsoleTapLimit { .. } => CONSOLE_TAP_LIMIT_REFUSED_CODE,
379        }
380    }
381}
382
383/// A [`super::services::ConfiguredChannel`] could not be built.
384///
385/// Lane-internal plumbing, not part of the designed error taxonomy: it exists
386/// so the ONE construction function can fail in a way BOTH of its callers map
387/// without inspecting a message. The boot loop renders it into the exact
388/// `ServerError::ConfigValidation` strings it has always produced
389/// ([`Self::boot_message`]); registration renders it into the typed
390/// [`ChannelRegistryError`] variants above.
391#[derive(Debug)]
392pub(super) enum ChannelBuildError {
393    /// The JSON Schema document did not compile.
394    SchemaRejected {
395        /// The compiler's own diagnostic.
396        message: String,
397    },
398    /// Durable initialization over the shared store failed.
399    DurableInitFailed {
400        /// The library's own diagnostic.
401        message: String,
402    },
403}
404
405impl ChannelBuildError {
406    /// The boot path's message for this failure, byte-for-byte as the boot loop
407    /// has always formatted it.
408    pub(super) fn boot_message(&self, name: &str) -> String {
409        match self {
410            Self::SchemaRejected { message } => {
411                format!("failed to initialize channel '{name}': {message}")
412            }
413            Self::DurableInitFailed { message } => {
414                format!("failed to initialize durable channel '{name}': {message}")
415            }
416        }
417    }
418
419    /// The registration path's typed error for this failure.
420    pub(super) fn into_registry_error(self, name: &str) -> ChannelRegistryError {
421        match self {
422            Self::SchemaRejected { message } => ChannelRegistryError::SchemaRejected {
423                name: name.to_owned(),
424                message,
425            },
426            Self::DurableInitFailed { message } => ChannelRegistryError::DurableInitFailed {
427                name: name.to_owned(),
428                message,
429            },
430        }
431    }
432}
433
434/// The `AtomicU8` encoding of [`ChannelState`] on a roster entry: admitting.
435pub(super) const STATE_ACTIVE: u8 = 0;
436
437/// The `AtomicU8` encoding of [`ChannelState`] on a roster entry: refusing.
438///
439/// The transition `STATE_ACTIVE` → `STATE_QUIESCED` is performed by one
440/// `compare_exchange` with `Release` success ordering, AFTER the reason has been
441/// written to the entry's `OnceLock`. Every reader loads with `Acquire`, so a
442/// reader that observes this value happens-after the reason's write and can read
443/// it.
444pub(super) const STATE_QUIESCED: u8 = 1;
445
446/// The reason reported for an entry observed `STATE_QUIESCED` whose recorded
447/// reason is not readable.
448///
449/// Unreachable by construction: the reason is written strictly BEFORE the
450/// `Release` flip and read after an `Acquire` load of it. It exists because the
451/// workspace denies `unwrap`/`expect`/`panic`, so the impossible branch must
452/// still yield a value — and a value that names its own impossibility is the
453/// only honest one. Seeing this string in a refusal means the ordering above was
454/// broken, not that an operator supplied it.
455pub(super) const UNRECORDED_QUIESCE_REASON: &str = "quiesce reason unrecorded (ordering violated)";