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)";