Skip to main content

laser_wire/
hello.rs

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