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}