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