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