Skip to main content

laser_wire/
hello.rs

1use crate::destination::{BackendResourceId, FileFormat, TableFormat};
2use crate::error::InvalidError;
3use crate::filter::{FILTER_EVALUATOR_VERSION, FilterCodec};
4use crate::query::{Consistency, SqlDialect};
5use crate::schema::LogicalTypeKind;
6use crate::topology::WireTopology;
7use crate::validate::Validate;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10
11/// Capability feature bits advertised in [`OpVersions::features`]. Each constant
12/// names one managed sub-feature a server serves beyond the base surface, so a
13/// binary client feature-detects it (before attempting the op) the way the HTTP
14/// surface reads the boolean flags on `Capabilities`. Additive and pinned
15/// cross-repo: a new bit is set by a newer server and ignored by an older
16/// client (which simply does not light up that capability).
17pub mod feature {
18    /// The key-value store serves compare-and-swap (`AGDX_KV_CAS`).
19    pub const KV_CAS: u64 = 1 << 0;
20    /// The query surface honors `Consistency::ReadYourWrites`.
21    pub const READ_YOUR_WRITES: u64 = 1 << 1;
22    /// The query surface honors `Consistency::Strong`.
23    pub const STRONG_CONSISTENCY: u64 = 1 << 2;
24    /// The key-value store serves fenced compare-and-swap (`AGDX_KV_CAS_FENCED`).
25    pub const KV_CAS_FENCED: u64 = 1 << 3;
26    /// The plane serves the agent and workflow control band (`AGDX_AGENT_*`).
27    pub const AGENT_WORKFLOW: u64 = 1 << 4;
28    /// The query surface serves lexical relevance search (`Query.text`).
29    pub const KEYWORD_SEARCH: u64 = 1 << 5;
30    /// The deployment publishes the change feed (`ChangeRecord`s on the
31    /// changes topic) for bindings that opt into `notify`.
32    pub const WATCH: u64 = 1 << 6;
33    /// The streaming server serves the authorization control band (`AGDX_AUTHZ_*`).
34    pub const AUTHZ: u64 = 1 << 7;
35    /// The deployment serves destination and checkpoint lifecycle operations.
36    pub const DESTINATIONS: u64 = 1 << 8;
37    /// The key-value store serves the revocable fenced-lease contract at
38    /// `KV_LEASE_OP_VERSION`: holder-scoped acquire, renewal
39    /// (`AGDX_KV_LEASE_RENEW`), holder-and-fence-validated release, fenced
40    /// compare-and-swap requiring a live lease, and the barriered read
41    /// (`KvGet::min_position`). Subsumes [`KV_CAS_FENCED`]: a client must not
42    /// send the reshaped lease ops (or a barriered read) to a server without
43    /// this bit, which would silently decode them under the old contract.
44    pub const KV_FENCED_LEASES: u64 = 1 << 9;
45    /// The streaming server serves consumer filters: filtered reads, fenced
46    /// acknowledgments, previews, sample tests, and validation
47    /// (`AGDX_FILTERED_POLL` and the rest of the delivery band's server-native
48    /// decade). Server-native, so it holds with the managed backend disabled.
49    /// The saved-filter catalog additionally needs the backend's `filter` op
50    /// version.
51    pub const CONSUMER_FILTERS: u64 = 1 << 10;
52}
53
54/// The wire op versions a server accepts, one per surface, plus the capability
55/// feature bits it advertises. A pinned wire shape, mirrored by the HTTP
56/// capabilities `versions` block.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[non_exhaustive]
59pub struct OpVersions {
60    pub query: u32,
61    pub control: u32,
62    pub kv: u32,
63    pub fork: u32,
64    /// The agent envelope (AGDX) version LaserData Cloud consumes for its
65    /// conversation projections. `0` means "not advertised" and is skipped on
66    /// encode, so pre-AGDX hello frames stay byte-identical and decode unchanged.
67    #[serde(default, skip_serializing_if = "is_zero")]
68    pub agent: u32,
69    /// The knowledge-graph op version served. `0` means not served, skipped on
70    /// encode so a pre-graph hello frame stays byte-identical. Mirrors the
71    /// `managed_graph` HTTP capability flag. (Agentic memory rides this plus the
72    /// query surface, so it has no op version of its own.)
73    #[serde(default, skip_serializing_if = "is_zero")]
74    pub graph: u32,
75    /// Destination and checkpoint operation version. Zero means not served.
76    #[serde(default, skip_serializing_if = "is_zero")]
77    pub checkpoint: u32,
78    /// The consumer-filter catalog op version served by the managed backend. Zero
79    /// means the catalog (saved filters and group bindings) is not served.
80    #[serde(default, skip_serializing_if = "is_zero")]
81    pub filter: u32,
82    /// Capability feature bits (see [`feature`]): managed sub-features served
83    /// beyond the base surface (compare-and-swap, read-your-writes, strong
84    /// consistency). `0` (the default) is skipped on encode, so a pre-feature
85    /// hello reply stays byte-identical and an old client just sees no extra
86    /// capabilities.
87    #[serde(default, skip_serializing_if = "is_zero_u64")]
88    pub features: u64,
89}
90
91fn is_zero(value: &u32) -> bool {
92    *value == 0
93}
94
95fn is_zero_u64(value: &u64) -> bool {
96    *value == 0
97}
98
99impl OpVersions {
100    /// Versions per surface. The struct is `#[non_exhaustive]` (new surfaces
101    /// land without a breaking change), so this is the constructor.
102    pub fn new(query: u32, control: u32, kv: u32, fork: u32) -> Self {
103        Self {
104            query,
105            control,
106            kv,
107            fork,
108            agent: 0,
109            graph: 0,
110            checkpoint: 0,
111            filter: 0,
112            features: 0,
113        }
114    }
115
116    /// Returns a copy advertising this agent-envelope (AGDX) version.
117    #[must_use]
118    pub fn with_agent(mut self, agent: u32) -> Self {
119        self.agent = agent;
120        self
121    }
122
123    /// Returns a copy advertising the knowledge-graph op version served.
124    #[must_use]
125    pub fn with_graph(mut self, graph: u32) -> Self {
126        self.graph = graph;
127        self
128    }
129
130    /// Returns a copy advertising the destination and checkpoint version.
131    #[must_use]
132    pub fn with_checkpoint(mut self, checkpoint: u32) -> Self {
133        self.checkpoint = checkpoint;
134        self
135    }
136
137    /// Returns a copy advertising the consumer-filter catalog version served.
138    #[must_use]
139    pub fn with_filter(mut self, filter: u32) -> Self {
140        self.filter = filter;
141        self
142    }
143
144    /// Returns a copy advertising the capability feature bits in `features`
145    /// (an OR of [`feature`] constants).
146    #[must_use]
147    pub fn with_features(mut self, features: u64) -> Self {
148        self.features = features;
149        self
150    }
151
152    /// Whether a [`feature`] bit (or set of bits) is advertised.
153    pub const fn has_feature(&self, bit: u64) -> bool {
154        self.features & bit == bit
155    }
156}
157
158/// Body of the `AGDX_HELLO` probe reply: the wire op versions the server (and
159/// its managed backend) accepts, mirroring the HTTP capabilities `versions`
160/// block. A pinned wire shape. Pre-versioned
161/// servers answer the probe with an empty body, which a client treats as "no
162/// versions advertised", never an error.
163#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
164#[non_exhaustive]
165pub struct HelloReply {
166    pub versions: OpVersions,
167}
168
169impl HelloReply {
170    /// Constructor for the non-exhaustive wire struct.
171    pub fn new(versions: OpVersions) -> Self {
172        Self { versions }
173    }
174}
175
176pub const BACKEND_DESCRIPTOR_VERSION: u32 = 1;
177
178/// One observed backend resource and its structured, secret-free capabilities.
179#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[non_exhaustive]
181pub struct BackendDescriptor {
182    pub descriptor_version: u32,
183    pub resource_id: BackendResourceId,
184    pub mode: BackendMode,
185    pub label: String,
186    pub implementation: BackendImplementation,
187    pub observed_backend_generation: u64,
188    pub runtime_configuration_revision: u64,
189    pub desired_state: BackendDesiredState,
190    pub observed_state: BackendObservedState,
191    pub readiness: BackendReadiness,
192    pub materialization: Vec<MaterializationCapability>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub query: Option<QueryCapabilities>,
195    pub schema: SchemaCapabilities,
196    pub maintenance: MaintenanceCapabilities,
197    pub limits: BackendLimits,
198}
199
200impl BackendDescriptor {
201    pub fn new(
202        resource_id: BackendResourceId,
203        mode: BackendMode,
204        label: impl Into<String>,
205        implementation: BackendImplementation,
206        observed_backend_generation: u64,
207        runtime_configuration_revision: u64,
208    ) -> Self {
209        Self {
210            descriptor_version: BACKEND_DESCRIPTOR_VERSION,
211            resource_id,
212            mode,
213            label: label.into(),
214            implementation,
215            observed_backend_generation,
216            runtime_configuration_revision,
217            desired_state: BackendDesiredState::Disabled,
218            observed_state: BackendObservedState::Disabled,
219            readiness: BackendReadiness::not_ready(BackendReadinessCode::Disabled),
220            materialization: Vec::new(),
221            query: None,
222            schema: SchemaCapabilities::default(),
223            maintenance: MaintenanceCapabilities::default(),
224            limits: BackendLimits::default(),
225        }
226    }
227
228    #[must_use]
229    pub fn with_state(
230        mut self,
231        desired_state: BackendDesiredState,
232        observed_state: BackendObservedState,
233        readiness: BackendReadiness,
234    ) -> Self {
235        self.desired_state = desired_state;
236        self.observed_state = observed_state;
237        self.readiness = readiness;
238        self
239    }
240
241    #[must_use]
242    pub fn with_materialization(mut self, capabilities: Vec<MaterializationCapability>) -> Self {
243        self.materialization = capabilities;
244        self
245    }
246
247    #[must_use]
248    pub fn with_query(mut self, capabilities: QueryCapabilities) -> Self {
249        self.query = Some(capabilities);
250        self
251    }
252
253    #[must_use]
254    pub fn with_schema(mut self, capabilities: SchemaCapabilities) -> Self {
255        self.schema = capabilities;
256        self
257    }
258
259    #[must_use]
260    pub fn with_maintenance(mut self, capabilities: MaintenanceCapabilities) -> Self {
261        self.maintenance = capabilities;
262        self
263    }
264
265    #[must_use]
266    pub fn with_limits(mut self, limits: BackendLimits) -> Self {
267        self.limits = limits;
268        self
269    }
270}
271
272impl Validate for BackendDescriptor {
273    fn validate(&self) -> Result<(), InvalidError> {
274        if self.descriptor_version != BACKEND_DESCRIPTOR_VERSION {
275            return Err(InvalidError::new(format!(
276                "backend descriptor version must be {BACKEND_DESCRIPTOR_VERSION}"
277            )));
278        }
279        if self.resource_id.as_u128() == 0
280            || self.observed_backend_generation == 0
281            || self.runtime_configuration_revision == 0
282        {
283            return Err(InvalidError::new(
284                "backend identity and observed revisions must be nonzero",
285            ));
286        }
287        validate_descriptor_text("backend label", &self.label)?;
288        validate_descriptor_text("backend implementation kind", &self.implementation.kind)?;
289        validate_descriptor_text(
290            "backend implementation version",
291            &self.implementation.version,
292        )?;
293        self.readiness.validate()?;
294        if self.readiness.ready && self.observed_state != BackendObservedState::Ready {
295            return Err(InvalidError::new(
296                "ready backend must report the ready observed state",
297            ));
298        }
299        let mut materialization = BTreeSet::new();
300        for capability in &self.materialization {
301            let key = (capability.file_format as u8, capability.table_format as u8);
302            if !materialization.insert(key) {
303                return Err(InvalidError::new(
304                    "backend repeats a materialization capability",
305                ));
306            }
307        }
308        if let Some(query) = &self.query {
309            query.validate()?;
310        }
311        if self.readiness.ready {
312            if self.schema.logical_schema && self.limits.max_schema_fields == 0 {
313                return Err(InvalidError::new(
314                    "ready logical-schema backend must advertise a schema-field limit",
315                ));
316            }
317            if !self.materialization.is_empty() && self.limits.max_materialization_file_bytes == 0 {
318                return Err(InvalidError::new(
319                    "ready materialization backend must advertise a file-size limit",
320                ));
321            }
322            if self.query.is_some()
323                && (self.limits.max_query_rows == 0
324                    || self.limits.max_query_bytes == 0
325                    || self.limits.max_scan_bytes == 0
326                    || self.limits.max_query_micros == 0
327                    || self.limits.max_concurrent_queries == 0)
328            {
329                return Err(InvalidError::new(
330                    "ready query backend must advertise nonzero query limits",
331                ));
332            }
333        }
334        Ok(())
335    }
336}
337
338#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340#[non_exhaustive]
341pub enum BackendMode {
342    Operational,
343    Lakehouse,
344}
345
346#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
347pub struct BackendImplementation {
348    pub kind: String,
349    pub version: String,
350}
351
352#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354#[non_exhaustive]
355pub enum BackendDesiredState {
356    Disabled,
357    Enabled,
358}
359
360#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
361#[serde(rename_all = "snake_case")]
362#[non_exhaustive]
363pub enum BackendObservedState {
364    Disabled,
365    Starting,
366    Ready,
367    Degraded,
368    Unavailable,
369}
370
371#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
372pub struct BackendReadiness {
373    pub ready: bool,
374    pub reasons: Vec<BackendReadinessReason>,
375    pub observed_at_micros: u64,
376}
377
378impl BackendReadiness {
379    pub fn not_ready(code: BackendReadinessCode) -> Self {
380        Self {
381            ready: false,
382            reasons: vec![BackendReadinessReason { code, detail: None }],
383            observed_at_micros: 0,
384        }
385    }
386
387    pub fn ready(observed_at_micros: u64) -> Self {
388        Self {
389            ready: true,
390            reasons: Vec::new(),
391            observed_at_micros,
392        }
393    }
394}
395
396impl Validate for BackendReadiness {
397    fn validate(&self) -> Result<(), InvalidError> {
398        if self.ready {
399            if !self.reasons.is_empty() || self.observed_at_micros == 0 {
400                return Err(InvalidError::new(
401                    "ready backend requires an observation time and no failure reasons",
402                ));
403            }
404            return Ok(());
405        }
406        if self.reasons.is_empty() {
407            return Err(InvalidError::new(
408                "not-ready backend requires at least one stable reason",
409            ));
410        }
411        if self.reasons.len() > 32 {
412            return Err(InvalidError::new(
413                "backend readiness reason count exceeds cap 32",
414            ));
415        }
416        for reason in &self.reasons {
417            if let Some(detail) = &reason.detail {
418                validate_descriptor_text("backend readiness detail", detail)?;
419            }
420        }
421        Ok(())
422    }
423}
424
425#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
426pub struct BackendReadinessReason {
427    pub code: BackendReadinessCode,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub detail: Option<String>,
430}
431
432#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case")]
434#[non_exhaustive]
435pub enum BackendReadinessCode {
436    Disabled,
437    ConfigurationPending,
438    ConfigurationRejected,
439    CredentialUnavailable,
440    ObjectStoreUnavailable,
441    CatalogUnavailable,
442    QueryRuntimeUnavailable,
443    GenerationMismatch,
444    ProbeFailed,
445}
446
447#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
448pub struct MaterializationCapability {
449    pub file_format: FileFormat,
450    pub table_format: TableFormat,
451    pub create_table: bool,
452    pub append: bool,
453}
454
455#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
456pub struct QueryCapabilities {
457    pub dialects: Vec<SqlDialect>,
458    pub time_travel: Vec<TimeTravelCapability>,
459    pub consistency: Vec<Consistency>,
460    pub logical_types: Vec<LogicalTypeKind>,
461    pub paging: Vec<QueryPagingCapability>,
462    pub cancellation: bool,
463    pub execution_status: bool,
464    pub raw_sql: bool,
465}
466
467impl Validate for QueryCapabilities {
468    fn validate(&self) -> Result<(), InvalidError> {
469        if self.dialects.is_empty()
470            || self.consistency.is_empty()
471            || self.logical_types.is_empty()
472            || self.paging.is_empty()
473        {
474            return Err(InvalidError::new(
475                "query capabilities require dialect, consistency, and logical-type coverage",
476            ));
477        }
478        ensure_unique(
479            "query dialect",
480            self.dialects.iter().map(|value| *value as u8),
481        )?;
482        ensure_unique(
483            "query time-travel capability",
484            self.time_travel.iter().map(|value| *value as u8),
485        )?;
486        ensure_unique(
487            "query consistency",
488            self.consistency.iter().map(|value| *value as u8),
489        )?;
490        ensure_unique(
491            "query logical type",
492            self.logical_types.iter().map(|value| *value as u8),
493        )?;
494        ensure_unique(
495            "query paging capability",
496            self.paging.iter().map(|value| *value as u8),
497        )?;
498        if self.raw_sql && self.dialects.is_empty() {
499            return Err(InvalidError::new(
500                "raw SQL capability requires an explicit dialect",
501            ));
502        }
503        Ok(())
504    }
505}
506
507fn ensure_unique(label: &str, values: impl IntoIterator<Item = u8>) -> Result<(), InvalidError> {
508    let mut seen = BTreeSet::new();
509    if values.into_iter().all(|value| seen.insert(value)) {
510        Ok(())
511    } else {
512        Err(InvalidError::new(format!("backend repeats a {label}")))
513    }
514}
515
516fn validate_descriptor_text(label: &str, value: &str) -> Result<(), InvalidError> {
517    if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) {
518        return Err(InvalidError::new(format!(
519            "{label} must contain 1..=4096 bytes without control characters"
520        )));
521    }
522    Ok(())
523}
524
525#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
526#[serde(rename_all = "snake_case")]
527#[non_exhaustive]
528pub enum TimeTravelCapability {
529    SnapshotId,
530    TimestampMicros,
531}
532
533#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535#[non_exhaustive]
536pub enum QueryPagingCapability {
537    Offset,
538    Cursor,
539}
540
541#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
542pub struct SchemaCapabilities {
543    pub logical_schema: bool,
544    pub arrow_ipc_stream: bool,
545    pub schema_evolution: bool,
546}
547
548#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
549pub struct MaintenanceCapabilities {
550    pub expire_snapshots: bool,
551    pub remove_orphan_files: bool,
552    pub compact_data_files: bool,
553}
554
555#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
556pub struct BackendLimits {
557    pub max_query_rows: u64,
558    pub max_query_bytes: u64,
559    pub max_scan_bytes: u64,
560    pub max_query_micros: u64,
561    pub max_concurrent_queries: u32,
562    pub max_schema_fields: u32,
563    pub max_materialization_file_bytes: u64,
564}
565
566/// The managed backend's capability announcement to the streaming server, sent over their
567/// private socket on connect (`AGDX_BACKEND_HELLO_CODE`). The streaming server caches the
568/// `versions` and the advertised `backends`, and relays them verbatim when it answers a
569/// client `AGDX_HELLO` / capabilities probe, so the streaming server never hardcodes feature
570/// bits or backend identities the backend may or may not serve.
571/// This makes the backend the single source of its own capability truth and
572/// keeps the binary `features` bitset and the HTTP capability flags in agreement
573/// with what is actually served. A separate type from [`HelloReply`] because the
574/// direction and sender differ (backend to streaming server, not server to client).
575const fn backend_ready_by_default() -> bool {
576    true
577}
578
579const fn backend_is_ready(ready: &bool) -> bool {
580    *ready
581}
582
583#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
584#[non_exhaustive]
585pub struct BackendAnnounce {
586    pub versions: OpVersions,
587    #[serde(
588        default = "backend_ready_by_default",
589        skip_serializing_if = "backend_is_ready"
590    )]
591    pub ready: bool,
592    /// Materialization backends the server currently exposes (the ones it has
593    /// open). A client routes only to an advertised id. Empty (the default) is
594    /// skipped on encode, so a pre-backends announce stays byte-identical and an
595    /// older reader simply sees no advertised backends.
596    #[serde(default, skip_serializing_if = "Vec::is_empty")]
597    pub backends: Vec<BackendDescriptor>,
598    /// The stream/topic names this deployment uses. Absent (the default) is
599    /// skipped on encode, so a pre-topology announce stays byte-identical and
600    /// an older reader sees no advertised topology (falls back to its
601    /// own [`WireTopology::default`]).
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub topology: Option<WireTopology>,
604    /// The consumer-filter evaluation the streaming server serves, set with
605    /// the `CONSUMER_FILTERS` bit. Absent from an older server, whose client
606    /// then assumes its own evaluator.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub filters: Option<FilterAnnounce>,
609}
610
611/// The consumer-filter evaluation a server serves. A client refuses to run a
612/// filter under another evaluator version or with a codec the server does not
613/// list, so a version gap fails before the first page instead of filtering
614/// differently.
615#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
616pub struct FilterAnnounce {
617    pub evaluator_version: u32,
618    #[serde(default, skip_serializing_if = "Vec::is_empty")]
619    pub codecs: Vec<FilterCodec>,
620}
621
622impl FilterAnnounce {
623    /// What this build of the evaluator serves.
624    pub fn served() -> Self {
625        Self {
626            evaluator_version: FILTER_EVALUATOR_VERSION,
627            codecs: vec![
628                FilterCodec::Json,
629                FilterCodec::HeadersOnly,
630                FilterCodec::Avro,
631                FilterCodec::Protobuf,
632                FilterCodec::Cbor,
633            ],
634        }
635    }
636
637    /// Whether a filter with `evaluator_version` and `codec` evaluates here
638    /// exactly as it would in the client that built it.
639    pub fn evaluates(&self, evaluator_version: u32, codec: FilterCodec) -> bool {
640        self.evaluator_version == evaluator_version && self.codecs.contains(&codec)
641    }
642}
643
644impl BackendAnnounce {
645    /// Constructor for the non-exhaustive wire struct.
646    pub fn new(versions: OpVersions) -> Self {
647        Self {
648            versions,
649            ready: true,
650            backends: Vec::new(),
651            topology: None,
652            filters: None,
653        }
654    }
655
656    /// Returns a copy announcing the served consumer-filter evaluation.
657    #[must_use]
658    pub fn with_filters(mut self, filters: FilterAnnounce) -> Self {
659        self.filters = Some(filters);
660        self
661    }
662
663    #[must_use]
664    pub const fn unavailable(mut self) -> Self {
665        self.ready = false;
666        self
667    }
668
669    /// Returns a copy advertising `backends`.
670    #[must_use]
671    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
672        self.backends = backends;
673        self
674    }
675
676    /// Returns a copy advertising the deployment's `topology`.
677    #[must_use]
678    pub fn with_topology(mut self, topology: WireTopology) -> Self {
679        self.topology = Some(topology);
680        self
681    }
682}
683
684impl Validate for BackendAnnounce {
685    fn validate(&self) -> Result<(), InvalidError> {
686        let mut resources = BTreeSet::new();
687        for backend in &self.backends {
688            backend.validate()?;
689            if !resources.insert(backend.resource_id) {
690                return Err(InvalidError::new(
691                    "backend announcement repeats a resource id",
692                ));
693            }
694        }
695        if self.ready
696            && !self.backends.is_empty()
697            && !self.backends.iter().any(|backend| backend.readiness.ready)
698        {
699            return Err(InvalidError::new(
700                "ready backend announcement must contain a ready backend",
701            ));
702        }
703        Ok(())
704    }
705}
706
707#[cfg(all(test, feature = "cbor"))]
708mod tests {
709    use super::*;
710    use crate::codes::{CONTROL_OP_VERSION, FORK_OP_VERSION, KV_OP_VERSION, QUERY_OP_VERSION};
711    use crate::framing::{decode_named, encode_named};
712
713    fn backend(resource_id: u128, mode: BackendMode, label: &str, kind: &str) -> BackendDescriptor {
714        BackendDescriptor::new(
715            BackendResourceId::from_u128(resource_id),
716            mode,
717            label,
718            BackendImplementation {
719                kind: kind.to_owned(),
720                version: "1.0.0".to_owned(),
721            },
722            1,
723            1,
724        )
725    }
726
727    #[test]
728    fn given_a_hello_reply_when_round_tripped_then_should_preserve_versions() {
729        // The pinned `HelloReply` shape (CBOR named fields). The connect-time
730        // probe decodes exactly this shape.
731        let reply = HelloReply::new(OpVersions::new(
732            QUERY_OP_VERSION,
733            CONTROL_OP_VERSION,
734            KV_OP_VERSION,
735            FORK_OP_VERSION,
736        ));
737        let bytes = encode_named(&reply).expect("hello reply serializes");
738        let back: HelloReply = decode_named(&bytes).expect("hello reply deserializes");
739        assert_eq!(back, reply);
740    }
741
742    #[test]
743    fn given_a_backend_announce_when_round_tripped_then_should_preserve_features() {
744        let announce = BackendAnnounce::new(
745            OpVersions::new(
746                QUERY_OP_VERSION,
747                CONTROL_OP_VERSION,
748                KV_OP_VERSION,
749                FORK_OP_VERSION,
750            )
751            .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES),
752        );
753        let bytes = encode_named(&announce).expect("serializes");
754        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
755        assert_eq!(back, announce);
756        assert!(back.versions.has_feature(feature::KV_CAS));
757    }
758
759    #[test]
760    fn given_an_unavailable_backend_announce_when_round_tripped_then_should_stay_unavailable() {
761        let announce = BackendAnnounce::new(OpVersions::new(
762            QUERY_OP_VERSION,
763            CONTROL_OP_VERSION,
764            KV_OP_VERSION,
765            FORK_OP_VERSION,
766        ))
767        .unavailable();
768        let bytes = encode_named(&announce).expect("serializes");
769        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
770        assert!(!back.ready);
771    }
772
773    #[test]
774    fn given_an_empty_hello_body_when_decoded_then_should_yield_no_versions() {
775        // Pre-versioned servers answer the probe with an empty body. The probe
776        // treats a failed decode as "no versions advertised", never an error.
777        assert!(decode_named::<HelloReply>(&[]).is_err());
778    }
779
780    #[test]
781    fn given_advertised_backends_when_round_tripped_then_should_preserve_them_and_skip_empty() {
782        let announce = BackendAnnounce::new(OpVersions::new(
783            QUERY_OP_VERSION,
784            CONTROL_OP_VERSION,
785            KV_OP_VERSION,
786            FORK_OP_VERSION,
787        ))
788        .with_backends(vec![
789            backend(1, BackendMode::Operational, "Embedded", "embedded"),
790            backend(2, BackendMode::Lakehouse, "Analytics warehouse", "columnar"),
791        ]);
792        let bytes = encode_named(&announce).expect("encodes");
793        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
794        assert_eq!(back, announce);
795        assert_eq!(back.backends.len(), 2);
796        assert_eq!(
797            back.backends[1].resource_id,
798            BackendResourceId::from_u128(2)
799        );
800        assert_eq!(back.backends[1].implementation.kind, "columnar");
801        assert_eq!(back.backends[1].label, "Analytics warehouse");
802
803        // No advertised backends (the default) is omitted on the wire, so a
804        // pre-backends announce stays byte-identical.
805        let plain = BackendAnnounce::new(OpVersions::new(1, 1, 1, 1));
806        let json = serde_json::to_string(&plain).expect("json");
807        assert!(!json.contains("backends"), "empty backends omitted: {json}");
808    }
809
810    #[test]
811    fn given_announce_without_topology_when_decoded_then_should_default_none() {
812        let announce = BackendAnnounce::new(OpVersions::new(
813            QUERY_OP_VERSION,
814            CONTROL_OP_VERSION,
815            KV_OP_VERSION,
816            FORK_OP_VERSION,
817        ));
818        let bytes = encode_named(&announce).expect("encodes");
819        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
820        assert_eq!(back.topology, None);
821        let json = serde_json::to_string(&announce).expect("json");
822        assert!(
823            !json.contains("topology"),
824            "absent topology omitted: {json}"
825        );
826    }
827
828    #[test]
829    fn given_announce_with_topology_when_round_tripped_then_should_preserve_it() {
830        let custom = WireTopology {
831            ops_stream: "custom-ops".to_owned(),
832            ..WireTopology::default()
833        };
834        let announce = BackendAnnounce::new(OpVersions::new(
835            QUERY_OP_VERSION,
836            CONTROL_OP_VERSION,
837            KV_OP_VERSION,
838            FORK_OP_VERSION,
839        ))
840        .with_topology(custom.clone());
841        let bytes = encode_named(&announce).expect("encodes");
842        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
843        assert_eq!(back.topology, Some(custom));
844    }
845
846    #[test]
847    fn given_advertised_features_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
848        let versions = OpVersions::new(
849            QUERY_OP_VERSION,
850            CONTROL_OP_VERSION,
851            KV_OP_VERSION,
852            FORK_OP_VERSION,
853        )
854        .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES);
855        assert!(versions.has_feature(feature::KV_CAS));
856        assert!(versions.has_feature(feature::READ_YOUR_WRITES));
857        assert!(!versions.has_feature(feature::STRONG_CONSISTENCY));
858        // has_feature on a combined mask requires every bit present.
859        assert!(versions.has_feature(feature::KV_CAS | feature::READ_YOUR_WRITES));
860        assert!(!versions.has_feature(feature::KV_CAS | feature::STRONG_CONSISTENCY));
861        let reply = HelloReply::new(versions);
862        let bytes = encode_named(&reply).expect("encodes");
863        let back: HelloReply = decode_named(&bytes).expect("decodes");
864        assert_eq!(back, reply);
865        assert!(back.versions.has_feature(feature::READ_YOUR_WRITES));
866        // No advertised feature (0) is omitted on the wire, so a pre-feature
867        // hello reply stays byte-identical.
868        let plain = HelloReply::new(OpVersions::new(1, 1, 1, 1));
869        let json = serde_json::to_string(&plain).expect("json");
870        assert!(!json.contains("features"), "zero features omitted: {json}");
871    }
872
873    #[test]
874    fn given_announced_filter_evaluation_when_round_tripped_then_should_gate_codecs_and_versions() {
875        let announce = BackendAnnounce::new(OpVersions::new(1, 1, 1, 1))
876            .with_filters(FilterAnnounce::served());
877        let bytes = encode_named(&announce).expect("serializes");
878        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
879        let filters = back.filters.expect("the evaluation is announced");
880        assert!(filters.evaluates(FILTER_EVALUATOR_VERSION, FilterCodec::Json));
881        assert!(!filters.evaluates(FILTER_EVALUATOR_VERSION + 1, FilterCodec::Json));
882        assert!(!filters.evaluates(FILTER_EVALUATOR_VERSION, FilterCodec::Unknown));
883        let plain =
884            encode_named(&BackendAnnounce::new(OpVersions::new(1, 1, 1, 1))).expect("serializes");
885        let older: BackendAnnounce = decode_named(&plain).expect("deserializes");
886        assert_eq!(older.filters, None);
887    }
888}