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