Skip to main content

laser_wire/
hello.rs

1use crate::topology::WireTopology;
2use serde::{Deserialize, Serialize};
3
4/// Capability feature bits advertised in [`OpVersions::features`]. Each constant
5/// names one managed sub-feature a server serves beyond the base surface, so a
6/// binary client feature-detects it (before attempting the op) the way the HTTP
7/// surface reads the boolean flags on `Capabilities`. Additive and pinned
8/// cross-repo: a new bit is set by a newer server and ignored by an older
9/// client (which simply does not light up that capability).
10pub mod feature {
11    /// The key-value store serves compare-and-swap (`AGDX_KV_CAS`).
12    pub const KV_CAS: u64 = 1 << 0;
13    /// The query surface honors `Consistency::ReadYourWrites`.
14    pub const READ_YOUR_WRITES: u64 = 1 << 1;
15    /// The query surface honors `Consistency::Strong`.
16    pub const STRONG_CONSISTENCY: u64 = 1 << 2;
17    /// The key-value store serves fenced compare-and-swap (`AGDX_KV_CAS_FENCED`).
18    pub const KV_CAS_FENCED: u64 = 1 << 3;
19    /// The plane serves the agent and workflow control band (`AGDX_AGENT_*`).
20    pub const AGENT_WORKFLOW: u64 = 1 << 4;
21    /// The query surface serves lexical relevance search (`Query.text`).
22    pub const KEYWORD_SEARCH: u64 = 1 << 5;
23    /// The deployment publishes the change feed (`ChangeRecord`s on the
24    /// changes topic) for bindings that opt into `notify`.
25    pub const WATCH: u64 = 1 << 6;
26    /// The streaming server serves the authorization control band (`AGDX_AUTHZ_*`).
27    pub const AUTHZ: u64 = 1 << 7;
28}
29
30/// The wire op versions a server accepts, one per surface, plus the capability
31/// feature bits it advertises. A pinned wire shape, mirrored by the HTTP
32/// capabilities `versions` block.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct OpVersions {
36    pub query: u32,
37    pub control: u32,
38    pub kv: u32,
39    pub fork: u32,
40    /// The agent envelope (AGDX) version LaserData Cloud consumes for its
41    /// conversation projections. `0` means "not advertised" and is skipped on
42    /// encode, so pre-AGDX hello frames stay byte-identical and decode unchanged.
43    #[serde(default, skip_serializing_if = "is_zero")]
44    pub agent: u32,
45    /// The knowledge-graph op version served. `0` means not served, skipped on
46    /// encode so a pre-graph hello frame stays byte-identical. Mirrors the
47    /// `managed_graph` HTTP capability flag. (Agentic memory rides this plus the
48    /// query surface, so it has no op version of its own.)
49    #[serde(default, skip_serializing_if = "is_zero")]
50    pub graph: u32,
51    /// Capability feature bits (see [`feature`]): managed sub-features served
52    /// beyond the base surface (compare-and-swap, read-your-writes, strong
53    /// consistency). `0` (the default) is skipped on encode, so a pre-feature
54    /// hello reply stays byte-identical and an old client just sees no extra
55    /// capabilities.
56    #[serde(default, skip_serializing_if = "is_zero_u64")]
57    pub features: u64,
58}
59
60fn is_zero(value: &u32) -> bool {
61    *value == 0
62}
63
64fn is_zero_u64(value: &u64) -> bool {
65    *value == 0
66}
67
68impl OpVersions {
69    /// Versions per surface. The struct is `#[non_exhaustive]` (new surfaces
70    /// land without a breaking change), so this is the constructor.
71    pub fn new(query: u32, control: u32, kv: u32, fork: u32) -> Self {
72        Self {
73            query,
74            control,
75            kv,
76            fork,
77            agent: 0,
78            graph: 0,
79            features: 0,
80        }
81    }
82
83    /// Returns a copy advertising this agent-envelope (AGDX) version.
84    #[must_use]
85    pub fn with_agent(mut self, agent: u32) -> Self {
86        self.agent = agent;
87        self
88    }
89
90    /// Returns a copy advertising the knowledge-graph op version served.
91    #[must_use]
92    pub fn with_graph(mut self, graph: u32) -> Self {
93        self.graph = graph;
94        self
95    }
96
97    /// Returns a copy advertising the capability feature bits in `features`
98    /// (an OR of [`feature`] constants).
99    #[must_use]
100    pub fn with_features(mut self, features: u64) -> Self {
101        self.features = features;
102        self
103    }
104
105    /// Whether a [`feature`] bit (or set of bits) is advertised.
106    pub const fn has_feature(&self, bit: u64) -> bool {
107        self.features & bit == bit
108    }
109}
110
111/// Body of the `AGDX_HELLO` probe reply: the wire op versions the server (and
112/// its managed backend) accepts, mirroring the HTTP capabilities `versions`
113/// block. A pinned wire shape. Pre-versioned
114/// servers answer the probe with an empty body, which a client treats as "no
115/// versions advertised", never an error.
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[non_exhaustive]
118pub struct HelloReply {
119    pub versions: OpVersions,
120}
121
122impl HelloReply {
123    /// Constructor for the non-exhaustive wire struct.
124    pub fn new(versions: OpVersions) -> Self {
125        Self { versions }
126    }
127}
128
129/// One materialization backend a server exposes, advertised so a client can see
130/// what it may route to. `id` is the stable handle a binding references, `kind`
131/// is the engine family as an opaque string, so a new engine is advertised by
132/// name without any wire change. Carries identity only, never settings or
133/// secrets. Integration-agnostic: the wire pins no specific engine.
134#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
135#[non_exhaustive]
136pub struct BackendDescriptor {
137    pub id: String,
138    pub kind: String,
139    /// Human-friendly display name for a UI, when the server has one. Advisory.
140    /// Absent (the default, skipped on the wire) means a client derives a label
141    /// from `id` or `kind`.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub label: Option<String>,
144    /// Engine or build version string, opaque to the wire. Advisory, for display
145    /// and compatibility hints. Absent (the default, skipped on the wire) means
146    /// the server did not report one.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub version: Option<String>,
149    /// Opaque capability tags the backend declares about itself, so a consumer
150    /// can reason about what this backend is good for (e.g. ingest, query, a
151    /// particular query-surface feature, or a storage trait) and gate a decision
152    /// before attempting an op. Each tag is an opaque string the wire pins no
153    /// meaning to: a producer emits what it supports and a consumer matches the
154    /// tags it understands, ignoring the rest, so a new capability is advertised
155    /// by name with no wire change. Integration-agnostic. Empty (the default,
156    /// skipped on the wire) means none declared.
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub capabilities: Vec<String>,
159}
160
161impl BackendDescriptor {
162    /// A descriptor for the backend at `id` of engine family `kind`.
163    pub fn new(id: impl Into<String>, kind: impl Into<String>) -> Self {
164        Self {
165            id: id.into(),
166            kind: kind.into(),
167            label: None,
168            version: None,
169            capabilities: Vec::new(),
170        }
171    }
172
173    /// Returns a copy with a human-friendly display label.
174    #[must_use]
175    pub fn with_label(mut self, label: impl Into<String>) -> Self {
176        self.label = Some(label.into());
177        self
178    }
179
180    /// Returns a copy advertising an engine or build version.
181    #[must_use]
182    pub fn with_version(mut self, version: impl Into<String>) -> Self {
183        self.version = Some(version.into());
184        self
185    }
186
187    /// Returns a copy advertising the opaque `capabilities` tags this backend
188    /// declares about itself.
189    #[must_use]
190    pub fn with_capabilities<I, S>(mut self, capabilities: I) -> Self
191    where
192        I: IntoIterator<Item = S>,
193        S: Into<String>,
194    {
195        self.capabilities = capabilities.into_iter().map(Into::into).collect();
196        self
197    }
198
199    /// Whether the backend declared the opaque capability `tag`.
200    pub fn has_capability(&self, tag: &str) -> bool {
201        self.capabilities.iter().any(|c| c == tag)
202    }
203}
204
205/// The managed backend's capability announcement to the streaming server, sent over their
206/// private socket on connect (`AGDX_BACKEND_HELLO_CODE`). The streaming server caches the
207/// `versions` and the advertised `backends`, and relays them verbatim when it answers a
208/// client `AGDX_HELLO` / capabilities probe, so the streaming server never hardcodes feature
209/// bits or backend identities the backend may or may not serve.
210/// This makes the backend the single source of its own capability truth and
211/// keeps the binary `features` bitset and the HTTP capability flags in agreement
212/// with what is actually served. A separate type from [`HelloReply`] because the
213/// direction and sender differ (backend to streaming server, not server to client).
214const fn backend_ready_by_default() -> bool {
215    true
216}
217
218const fn backend_is_ready(ready: &bool) -> bool {
219    *ready
220}
221
222#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
223#[non_exhaustive]
224pub struct BackendAnnounce {
225    pub versions: OpVersions,
226    #[serde(
227        default = "backend_ready_by_default",
228        skip_serializing_if = "backend_is_ready"
229    )]
230    pub ready: bool,
231    /// Materialization backends the server currently exposes (the ones it has
232    /// open). A client routes only to an advertised id. Empty (the default) is
233    /// skipped on encode, so a pre-backends announce stays byte-identical and an
234    /// older reader simply sees no advertised backends.
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub backends: Vec<BackendDescriptor>,
237    /// The stream/topic names this deployment uses. Absent (the default) is
238    /// skipped on encode, so a pre-topology announce stays byte-identical and
239    /// an older reader sees no advertised topology (falls back to its
240    /// own [`WireTopology::default`]).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub topology: Option<WireTopology>,
243}
244
245impl BackendAnnounce {
246    /// Constructor for the non-exhaustive wire struct.
247    pub fn new(versions: OpVersions) -> Self {
248        Self {
249            versions,
250            ready: true,
251            backends: Vec::new(),
252            topology: None,
253        }
254    }
255
256    #[must_use]
257    pub const fn unavailable(mut self) -> Self {
258        self.ready = false;
259        self
260    }
261
262    /// Returns a copy advertising `backends`.
263    #[must_use]
264    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
265        self.backends = backends;
266        self
267    }
268
269    /// Returns a copy advertising the deployment's `topology`.
270    #[must_use]
271    pub fn with_topology(mut self, topology: WireTopology) -> Self {
272        self.topology = Some(topology);
273        self
274    }
275}
276
277#[cfg(all(test, feature = "cbor"))]
278mod tests {
279    use super::*;
280    use crate::codes::{CONTROL_OP_VERSION, FORK_OP_VERSION, KV_OP_VERSION, QUERY_OP_VERSION};
281    use crate::framing::{decode_named, encode_named};
282
283    #[test]
284    fn given_a_hello_reply_when_round_tripped_then_should_preserve_versions() {
285        // The pinned `HelloReply` shape (CBOR named fields). The connect-time
286        // probe decodes exactly this shape.
287        let reply = HelloReply::new(OpVersions::new(
288            QUERY_OP_VERSION,
289            CONTROL_OP_VERSION,
290            KV_OP_VERSION,
291            FORK_OP_VERSION,
292        ));
293        let bytes = encode_named(&reply).expect("hello reply serializes");
294        let back: HelloReply = decode_named(&bytes).expect("hello reply deserializes");
295        assert_eq!(back, reply);
296    }
297
298    #[test]
299    fn given_a_backend_announce_when_round_tripped_then_should_preserve_features() {
300        let announce = BackendAnnounce::new(
301            OpVersions::new(
302                QUERY_OP_VERSION,
303                CONTROL_OP_VERSION,
304                KV_OP_VERSION,
305                FORK_OP_VERSION,
306            )
307            .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES),
308        );
309        let bytes = encode_named(&announce).expect("serializes");
310        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
311        assert_eq!(back, announce);
312        assert!(back.versions.has_feature(feature::KV_CAS));
313    }
314
315    #[test]
316    fn given_an_unavailable_backend_announce_when_round_tripped_then_should_stay_unavailable() {
317        let announce = BackendAnnounce::new(OpVersions::new(
318            QUERY_OP_VERSION,
319            CONTROL_OP_VERSION,
320            KV_OP_VERSION,
321            FORK_OP_VERSION,
322        ))
323        .unavailable();
324        let bytes = encode_named(&announce).expect("serializes");
325        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
326        assert!(!back.ready);
327    }
328
329    #[test]
330    fn given_an_empty_hello_body_when_decoded_then_should_yield_no_versions() {
331        // Pre-versioned servers answer the probe with an empty body. The probe
332        // treats a failed decode as "no versions advertised", never an error.
333        assert!(decode_named::<HelloReply>(&[]).is_err());
334    }
335
336    #[test]
337    fn given_advertised_backends_when_round_tripped_then_should_preserve_them_and_skip_empty() {
338        let announce = BackendAnnounce::new(OpVersions::new(
339            QUERY_OP_VERSION,
340            CONTROL_OP_VERSION,
341            KV_OP_VERSION,
342            FORK_OP_VERSION,
343        ))
344        .with_backends(vec![
345            BackendDescriptor::new("embedded", "embedded"),
346            BackendDescriptor::new("warehouse", "columnar")
347                .with_label("Analytics warehouse")
348                .with_version("2.1.0")
349                .with_capabilities(["ingest", "query", "percentile"]),
350        ]);
351        let bytes = encode_named(&announce).expect("encodes");
352        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
353        assert_eq!(back, announce);
354        assert_eq!(back.backends.len(), 2);
355        assert_eq!(back.backends[1].id, "warehouse");
356        assert_eq!(back.backends[1].kind, "columnar");
357        assert_eq!(
358            back.backends[1].label.as_deref(),
359            Some("Analytics warehouse")
360        );
361        assert_eq!(back.backends[1].version.as_deref(), Some("2.1.0"));
362        assert!(back.backends[1].has_capability("query"));
363        assert!(!back.backends[1].has_capability("vector_search"));
364        // The minimal descriptor omits the advisory fields on the wire.
365        assert_eq!(back.backends[0].label, None);
366        assert_eq!(back.backends[0].version, None);
367        assert!(back.backends[0].capabilities.is_empty());
368        let minimal_json = serde_json::to_string(&back.backends[0]).expect("json");
369        assert!(
370            !minimal_json.contains("label")
371                && !minimal_json.contains("version")
372                && !minimal_json.contains("capabilities"),
373            "absent advisory fields omitted: {minimal_json}"
374        );
375
376        // No advertised backends (the default) is omitted on the wire, so a
377        // pre-backends announce stays byte-identical.
378        let plain = BackendAnnounce::new(OpVersions::new(1, 1, 1, 1));
379        let json = serde_json::to_string(&plain).expect("json");
380        assert!(!json.contains("backends"), "empty backends omitted: {json}");
381    }
382
383    #[test]
384    fn given_announce_without_topology_when_decoded_then_should_default_none() {
385        let announce = BackendAnnounce::new(OpVersions::new(
386            QUERY_OP_VERSION,
387            CONTROL_OP_VERSION,
388            KV_OP_VERSION,
389            FORK_OP_VERSION,
390        ));
391        let bytes = encode_named(&announce).expect("encodes");
392        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
393        assert_eq!(back.topology, None);
394        let json = serde_json::to_string(&announce).expect("json");
395        assert!(
396            !json.contains("topology"),
397            "absent topology omitted: {json}"
398        );
399    }
400
401    #[test]
402    fn given_announce_with_topology_when_round_tripped_then_should_preserve_it() {
403        let custom = WireTopology {
404            ops_stream: "custom-ops".to_owned(),
405            ..WireTopology::default()
406        };
407        let announce = BackendAnnounce::new(OpVersions::new(
408            QUERY_OP_VERSION,
409            CONTROL_OP_VERSION,
410            KV_OP_VERSION,
411            FORK_OP_VERSION,
412        ))
413        .with_topology(custom.clone());
414        let bytes = encode_named(&announce).expect("encodes");
415        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
416        assert_eq!(back.topology, Some(custom));
417    }
418
419    #[test]
420    fn given_advertised_features_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
421        let versions = OpVersions::new(
422            QUERY_OP_VERSION,
423            CONTROL_OP_VERSION,
424            KV_OP_VERSION,
425            FORK_OP_VERSION,
426        )
427        .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES);
428        assert!(versions.has_feature(feature::KV_CAS));
429        assert!(versions.has_feature(feature::READ_YOUR_WRITES));
430        assert!(!versions.has_feature(feature::STRONG_CONSISTENCY));
431        // has_feature on a combined mask requires every bit present.
432        assert!(versions.has_feature(feature::KV_CAS | feature::READ_YOUR_WRITES));
433        assert!(!versions.has_feature(feature::KV_CAS | feature::STRONG_CONSISTENCY));
434        let reply = HelloReply::new(versions);
435        let bytes = encode_named(&reply).expect("encodes");
436        let back: HelloReply = decode_named(&bytes).expect("decodes");
437        assert_eq!(back, reply);
438        assert!(back.versions.has_feature(feature::READ_YOUR_WRITES));
439        // No advertised feature (0) is omitted on the wire, so a pre-feature
440        // hello reply stays byte-identical.
441        let plain = HelloReply::new(OpVersions::new(1, 1, 1, 1));
442        let json = serde_json::to_string(&plain).expect("json");
443        assert!(!json.contains("features"), "zero features omitted: {json}");
444    }
445}