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