Skip to main content

laser_wire/
http.rs

1use crate::error::InvalidError;
2use crate::filter::FilterGroupIdentity;
3use crate::fork::{ForkError, ForkKind};
4use crate::graph::SourceRef;
5use crate::hello::{BackendDescriptor, FilterAnnounce, OpVersions};
6use crate::kv::KvError;
7use crate::query::{Consistency, QueryError};
8use crate::result::ResultCode;
9use crate::topology::WireTopology;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::num::NonZeroU128;
13
14/// `GET /agdx/capabilities`: the feature-detection probe.
15pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
16/// `POST /agdx/query` (and `GET` with the query as a parameter).
17pub const QUERY_PATH: &str = "/agdx/query";
18/// `GET /agdx/projections` to list, `POST` to register.
19pub const PROJECTIONS_PATH: &str = "/agdx/projections";
20/// `POST /agdx/bindings` to apply, `DELETE` to remove.
21pub const BINDINGS_PATH: &str = "/agdx/bindings";
22/// `GET /agdx/schemas` to list, `POST` to register.
23pub const SCHEMAS_PATH: &str = "/agdx/schemas";
24/// `GET /agdx/kv` to list the caller's namespaces.
25pub const KV_PATH: &str = "/agdx/kv";
26/// `GET /agdx/forks` to list, `POST` to create.
27pub const FORKS_PATH: &str = "/agdx/forks";
28/// `GET /agdx/graphs` to list graph projections, `POST` to register.
29pub const GRAPHS_PATH: &str = "/agdx/graphs";
30/// Prefix of the graph traversal and neighbor routes, `/agdx/graph/{name}/...`.
31pub const GRAPH_PATH: &str = "/agdx/graph";
32/// `GET /agdx/clients` to list live connections with their advertised metadata,
33/// filtered and paginated by query parameters. The HTTP face of the
34/// `AGDX_GET_CLIENTS_METADATA` discovery read.
35pub const CLIENTS_PATH: &str = "/agdx/clients";
36/// `GET /agdx/authz/whoami`: the caller's effective governance roles and grants.
37pub const AUTHZ_WHOAMI_PATH: &str = "/agdx/authz/whoami";
38/// `GET /agdx/authz/roles`: list governance roles.
39pub const AUTHZ_ROLES_PATH: &str = "/agdx/authz/roles";
40/// `GET /agdx/filters`: list saved consumer filters.
41pub const FILTERS_PATH: &str = "/agdx/filters";
42pub const FILTER_METRICS_PATH: &str = "/agdx/filters/metrics";
43/// `POST /agdx/filters/mutations`: one catalog mutation with its outcome.
44pub const FILTER_MUTATIONS_PATH: &str = "/agdx/filters/mutations";
45/// `POST /agdx/filters/validate`: validate and compile a filter.
46pub const FILTER_VALIDATE_PATH: &str = "/agdx/filters/validate";
47/// `POST /agdx/filters/test`: evaluate a filter against a supplied sample.
48pub const FILTER_TEST_PATH: &str = "/agdx/filters/test";
49/// `POST /agdx/filters/preview`: preview a filter over stored records.
50pub const FILTER_PREVIEW_PATH: &str = "/agdx/filters/preview";
51/// `GET /agdx/filter-bindings`: list consumer-group filter bindings.
52pub const FILTER_BINDINGS_PATH: &str = "/agdx/filter-bindings";
53/// `GET /agdx/group-policies/{stream}/{topic}/{group}`: confirmed group policy.
54pub const GROUP_POLICIES_PATH: &str = "/agdx/group-policies";
55/// Base path for reads of sessions in one named stream.
56pub const SESSIONS_PATH: &str = "/agdx/sessions";
57
58pub fn sessions_path(stream: &str) -> String {
59    format!("{SESSIONS_PATH}/{}", path_segment(stream))
60}
61
62pub fn session_changes_path(stream: &str) -> String {
63    format!("{}/changes", sessions_path(stream))
64}
65
66pub fn session_path(stream: &str, id: crate::agent::ConversationId) -> String {
67    format!(
68        "{}/{}",
69        sessions_path(stream),
70        path_segment(&id.to_string())
71    )
72}
73
74pub fn session_events_path(stream: &str, id: crate::agent::ConversationId) -> String {
75    format!("{}/events", session_path(stream, id))
76}
77
78pub fn session_state_path(stream: &str, id: crate::agent::ConversationId) -> String {
79    format!("{}/state", session_path(stream, id))
80}
81
82pub fn session_links_path(stream: &str, id: crate::agent::ConversationId) -> String {
83    format!("{}/links", session_path(stream, id))
84}
85
86pub fn session_sources_path(stream: &str, id: crate::agent::ConversationId) -> String {
87    format!("{}/sources", session_path(stream, id))
88}
89
90/// `GET /agdx/filters/{id}`.
91pub fn filter_path(id: u32) -> String {
92    format!("{FILTERS_PATH}/{id}")
93}
94
95/// `GET /agdx/filters/{id}/revisions`.
96pub fn filter_revisions_path(id: u32) -> String {
97    format!("{FILTERS_PATH}/{id}/revisions")
98}
99
100/// `GET /agdx/filters/operations/{operation_id}`.
101pub fn filter_operation_path(operation_id: u128) -> String {
102    format!("{FILTERS_PATH}/operations/{operation_id}")
103}
104
105/// `GET /agdx/filter-bindings/{stream}/{topic}/{group}`. Each name is
106/// percent-encoded as one path segment, because Iggy names can hold `/`, `?`,
107/// or `#`.
108pub fn filter_binding_path(stream: &str, topic: &str, group: &str) -> String {
109    format!(
110        "{FILTER_BINDINGS_PATH}/{}/{}/{}",
111        path_segment(stream),
112        path_segment(topic),
113        path_segment(group)
114    )
115}
116
117pub fn group_policy_path(stream: &str, topic: &str, group: &str) -> String {
118    format!(
119        "{GROUP_POLICIES_PATH}/{}/{}/{}",
120        path_segment(stream),
121        path_segment(topic),
122        path_segment(group)
123    )
124}
125
126/// Percent-encode `text` as one URL path segment: every byte outside the
127/// RFC 3986 unreserved set becomes `%XX`.
128pub fn path_segment(text: &str) -> String {
129    let mut out = String::with_capacity(text.len());
130    for byte in text.bytes() {
131        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
132            out.push(char::from(byte));
133        } else {
134            out.push_str(&format!("%{byte:02X}"));
135        }
136    }
137    out
138}
139
140/// `GET`/`PUT`/`DELETE /agdx/authz/roles/{name}`, the name percent-encoded as
141/// one path segment.
142pub fn authz_role_path(name: &str) -> String {
143    format!("{AUTHZ_ROLES_PATH}/{}", path_segment(name))
144}
145
146/// `GET`/`PUT /agdx/authz/users/{id}/roles`: read or replace one user's role set.
147pub fn authz_user_roles_path(user_id: u32) -> String {
148    format!("/agdx/authz/users/{user_id}/roles")
149}
150
151/// `DELETE`/`GET /agdx/graphs/{id}`: drop or read a graph projection. The id
152/// is percent-encoded as one path segment, like every name below, because a
153/// stream-scoped name holds `/`.
154pub fn graph_path(id: &str) -> String {
155    format!("{GRAPHS_PATH}/{}", path_segment(id))
156}
157
158/// `POST /agdx/graph/{name}/query`: run a traversal (a `GraphQuery` body).
159pub fn graph_query_path(name: &str) -> String {
160    format!("{GRAPH_PATH}/{}/query", path_segment(name))
161}
162
163/// `GET /agdx/graph/{name}/neighbors/{node}`: one-hop neighbor read.
164pub fn graph_neighbors_path(name: &str, node: &str) -> String {
165    format!(
166        "{GRAPH_PATH}/{}/neighbors/{}",
167        path_segment(name),
168        path_segment(node)
169    )
170}
171
172/// `GET`/`DELETE /agdx/projections/{id}`.
173pub fn projection_path(id: &str) -> String {
174    format!("{PROJECTIONS_PATH}/{}", path_segment(id))
175}
176
177/// `GET`/`DELETE /agdx/schemas/{id}`.
178pub fn schema_path(id: u32) -> String {
179    format!("{SCHEMAS_PATH}/{id}")
180}
181
182/// `POST /agdx/schemas/{id}/decode`.
183pub fn schema_decode_path(id: u32) -> String {
184    format!("{SCHEMAS_PATH}/{id}/decode")
185}
186
187/// `GET /agdx/kv/{namespace}` to scan, `DELETE` to bulk-delete.
188pub fn kv_namespace_path(namespace: &str) -> String {
189    format!("{KV_PATH}/{}", path_segment(namespace))
190}
191
192/// `GET`/`PUT`/`DELETE /agdx/kv/{namespace}/{key}`. `key` is the URL-safe
193/// unpadded base64 form of the key bytes, the encoding this surface uses for
194/// every binary body.
195pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
196    format!("{}/{}", kv_namespace_path(namespace), path_segment(key_b64))
197}
198
199/// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write (compare-and-swap).
200/// The precondition rides the query string (`expect_version` or `expect_absent`)
201/// and the value rides the raw body, like the plain `PUT`. A success replies
202/// `CasCommittedView` with the new version, a precondition miss replies `409`
203/// with an `ErrorBody` of code `conflict` whose `detail` carries the current
204/// version.
205pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
206    format!("{}/cas", kv_entry_path(namespace, key_b64))
207}
208
209/// `DELETE /agdx/forks/{id}`.
210pub fn fork_path(id: &str) -> String {
211    format!("{FORKS_PATH}/{}", path_segment(id))
212}
213
214/// `POST /agdx/forks/{id}/promote`.
215pub fn fork_promote_path(id: &str) -> String {
216    format!("{}/promote", fork_path(id))
217}
218
219/// `PUT /agdx/forks/{id}/rows`.
220pub fn fork_rows_path(id: &str) -> String {
221    format!("{}/rows", fork_path(id))
222}
223
224/// `GET /agdx/capabilities` reply: what the `/agdx/*` surface offers on this
225/// server, so a browser client can feature-detect before showing the
226/// projections / query / KV / fork views. Richer than the binary `AGDX_HELLO`
227/// probe (per-surface flags plus the wire op versions the JSON bodies must
228/// match), and it answers truthfully even when the managed backend is disabled (200
229/// with `managed: false`).
230#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
232#[non_exhaustive]
233pub struct Capabilities {
234    /// Connected to a managed backend at all (the root: with no backend every managed
235    /// surface below is off, and the reply still answers `200` with `managed:
236    /// false`).
237    pub managed: bool,
238    /// The managed query surface, its registry browse views, and the strongest
239    /// read-consistency it serves.
240    pub query: QueryCapsView,
241    /// The managed key-value surface and its conditional-write support.
242    pub kv: KvCapsView,
243    /// Whether the knowledge-graph ops (traversal, neighbors) are served. The
244    /// agentic-memory API composes the query and graph surfaces, so it has no
245    /// flag of its own: a client reads `query` and `graph`.
246    #[serde(default)]
247    pub graph: bool,
248    /// Whether copy-on-write forks are served.
249    pub fork: bool,
250    /// Whether managed session reads are served.
251    #[serde(default)]
252    pub sessions: bool,
253    /// Whether the change feed is published (one change record per committed
254    /// notifying projector batch on the changes topic). Off by default: a
255    /// client that waits on an unpublished feed would wait forever.
256    #[serde(default)]
257    pub watch: bool,
258    /// Whether the authorization control band (`AGDX_AUTHZ_*`) is served, so a
259    /// console can show its roles and bindings surface. Off by default.
260    #[serde(default)]
261    pub authz: bool,
262    /// Consumer filters: server-side evaluation and the saved-filter catalog,
263    /// reported separately.
264    #[serde(default)]
265    pub filters: FilterCapsView,
266    pub versions: OpVersions,
267    /// Materialization backends the server currently exposes, so a client can
268    /// show what it may route to. Identity only (id + engine kind), no settings
269    /// or secrets. Empty (the default) is skipped on encode, so a pre-backends
270    /// capabilities reply stays byte-identical.
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub backends: Vec<BackendDescriptor>,
273    /// The stream/topic names this deployment uses, mirroring
274    /// [`crate::hello::BackendAnnounce::topology`]. Absent (the default) is
275    /// skipped on encode, so a pre-topology capabilities reply stays
276    /// byte-identical and an older reader falls back to
277    /// [`WireTopology::default`].
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub topology: Option<WireTopology>,
280}
281
282/// The managed query surface on the HTTP capabilities reply: whether it is
283/// served, its registry browse views, and the consistency it honors.
284#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286pub struct QueryCapsView {
287    /// Whether `POST /agdx/query` is served.
288    pub available: bool,
289    /// Whether the projection registry browse routes are served.
290    pub projections: bool,
291    /// Whether the schema registry browse routes are served.
292    pub schemas: bool,
293    /// The strongest read-consistency the surface serves (the ladder
294    /// `eventual < read_your_writes`, so a level implies the weaker ones). Defaults to `eventual`, which every query surface serves.
295    #[serde(default)]
296    pub consistency: Consistency,
297    /// Whether lexical relevance search (`Query.text`) is served. Defaults
298    /// off, like every sub-feature: over-advertising would turn the clean
299    /// unsupported into a silent wrong answer.
300    #[serde(default)]
301    pub keyword: bool,
302}
303
304/// Consumer filters on the HTTP capabilities reply. Native evaluation is served
305/// by the streaming server itself and holds with the managed backend off. The
306/// catalog (saved filters and group bindings) needs the backend.
307#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
308#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
309pub struct FilterCapsView {
310    /// Filtered reads, fenced acknowledgments, sample tests, and validation.
311    #[serde(default)]
312    pub native: bool,
313    /// Group-aware reads, including pass-through when no policy is bound.
314    #[serde(default)]
315    pub group_policy_reads: bool,
316    /// Bounded previews over stored records.
317    #[serde(default)]
318    pub preview: bool,
319    /// Filtered reads from the partition primary through an attached session.
320    #[serde(default)]
321    pub primary_routing: bool,
322    /// Saved filters, revisions, and mutation outcomes.
323    #[serde(default)]
324    pub catalog: bool,
325    /// Consumer groups bound to a saved revision.
326    #[serde(default)]
327    pub managed_groups: bool,
328    /// The evaluation rules served (`0` when native evaluation is off).
329    #[serde(default)]
330    pub evaluator_version: u32,
331    /// The payload codecs a filter may select.
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub codecs: Vec<crate::filter::FilterCodec>,
334}
335
336impl FilterCapsView {
337    /// The view a server serving native evaluation and a ready catalog advertises.
338    pub fn served(native: bool, catalog: bool) -> Self {
339        let evaluation = native.then(FilterAnnounce::served);
340        Self {
341            native,
342            group_policy_reads: false,
343            preview: native,
344            primary_routing: native,
345            catalog,
346            managed_groups: catalog,
347            evaluator_version: evaluation
348                .as_ref()
349                .map_or(0, |evaluation| evaluation.evaluator_version),
350            codecs: evaluation
351                .map(|evaluation| evaluation.codecs)
352                .unwrap_or_default(),
353        }
354    }
355
356    #[must_use]
357    pub const fn with_group_policy_reads(mut self, served: bool) -> Self {
358        self.group_policy_reads = served;
359        self
360    }
361
362    /// Returns a copy reporting the evaluation a server announced instead of
363    /// this build's own.
364    #[must_use]
365    pub fn with_evaluation(mut self, evaluation: FilterAnnounce) -> Self {
366        if self.native {
367            self.evaluator_version = evaluation.evaluator_version;
368            self.codecs = evaluation.codecs;
369        }
370        self
371    }
372}
373
374/// The managed key-value surface on the HTTP capabilities reply.
375#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
377pub struct KvCapsView {
378    /// Whether the get/set/scan routes are served.
379    pub available: bool,
380    /// Whether compare-and-swap (`AGDX_KV_CAS`) is served. Independent of plain
381    /// get/set: a backend that cannot do a conditional write leaves it off and a
382    /// `cas` returns a clean unsupported error.
383    #[serde(default)]
384    pub cas: bool,
385    /// Whether fenced compare-and-swap (`AGDX_KV_CAS_FENCED`) is served. Independent
386    /// of plain `cas`: a backend leaves it off when it cannot gate a write on a live
387    /// fence sequence.
388    #[serde(default)]
389    pub cas_fenced: bool,
390    /// Whether the full revocable fenced-lease contract is served: holder-scoped
391    /// acquire, renewal, release, live-lease fenced CAS, and barriered reads.
392    #[serde(default)]
393    pub fenced_leases: bool,
394}
395
396impl Capabilities {
397    /// Constructor for the non-exhaustive wire struct. The core surfaces track
398    /// `enabled`, the way the binary `AGDX_HELLO` probe answers. The per-surface
399    /// sub-features (`kv.cas`, `query.consistency` above `eventual`, `graph`) all
400    /// start off: a server must opt into each, never over-advertising (which would
401    /// turn a clean unsupported error into a silent wrong answer). Build them with
402    /// [`from_versions`](Self::from_versions) or the setters.
403    pub fn new(enabled: bool, versions: OpVersions) -> Self {
404        Self {
405            managed: enabled,
406            query: QueryCapsView {
407                available: enabled,
408                projections: enabled,
409                schemas: enabled,
410                consistency: Consistency::Eventual,
411                keyword: false,
412            },
413            kv: KvCapsView {
414                available: enabled,
415                cas: false,
416                cas_fenced: false,
417                fenced_leases: false,
418            },
419            graph: false,
420            fork: enabled,
421            sessions: false,
422            watch: false,
423            authz: false,
424            filters: FilterCapsView::default(),
425            versions,
426            backends: Vec::new(),
427            topology: None,
428        }
429    }
430
431    /// Advertise the consumer-filter surface.
432    #[must_use]
433    pub fn with_filters(mut self, filters: FilterCapsView) -> Self {
434        self.filters = filters;
435        self
436    }
437
438    /// Advertise that the knowledge-graph ops are served. Off by default: a
439    /// server sets it only when a backend implements the graph surface.
440    #[must_use]
441    pub fn with_graph(mut self, value: bool) -> Self {
442        self.graph = value;
443        self
444    }
445
446    /// Set whether lexical keyword search is served on the query surface.
447    #[must_use]
448    pub fn with_query_keyword(mut self, value: bool) -> Self {
449        self.query.keyword = value;
450        self
451    }
452
453    /// Advertise that managed session reads are served.
454    #[must_use]
455    pub fn with_sessions(mut self, value: bool) -> Self {
456        self.sessions = value;
457        self
458    }
459
460    /// Advertise that the change feed is published. Only a deployment whose
461    /// projector emits change records may set it.
462    #[must_use]
463    pub fn with_watch(mut self, value: bool) -> Self {
464        self.watch = value;
465        self
466    }
467
468    /// Advertise that the authorization control band is served.
469    #[must_use]
470    pub fn with_authz(mut self, value: bool) -> Self {
471        self.authz = value;
472        self
473    }
474
475    /// Advertise the materialization backends the server exposes. The wire pins
476    /// no engine, so a server lists whatever it has open by id and kind.
477    #[must_use]
478    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
479        self.backends = backends;
480        self
481    }
482
483    /// Advertise the deployment's `topology`, mirroring
484    /// [`crate::hello::BackendAnnounce::with_topology`].
485    #[must_use]
486    pub fn with_topology(mut self, topology: WireTopology) -> Self {
487        self.topology = Some(topology);
488        self
489    }
490
491    /// Advertise compare-and-swap on the KV surface (`AGDX_KV_CAS`). Only a
492    /// backend that does a genuine conditional write may set it.
493    #[must_use]
494    pub fn with_kv_cas(mut self, on: bool) -> Self {
495        self.kv.cas = on;
496        self
497    }
498
499    /// Advertise fenced compare-and-swap on the KV surface (`AGDX_KV_CAS_FENCED`).
500    /// Only a backend that gates a write on a live fence sequence may set it.
501    #[must_use]
502    pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
503        self.kv.cas_fenced = on;
504        self
505    }
506
507    /// Advertise the full revocable fenced-lease contract. This subsumes the
508    /// older fence-only `cas_fenced` capability.
509    #[must_use]
510    pub fn with_kv_fenced_leases(mut self, on: bool) -> Self {
511        self.kv.fenced_leases = on;
512        self.kv.cas_fenced |= on;
513        self
514    }
515
516    /// Advertise the strongest read-consistency the query surface serves.
517    #[must_use]
518    pub fn with_query_consistency(mut self, level: Consistency) -> Self {
519        self.query.consistency = level;
520        self
521    }
522
523    /// Build the HTTP capabilities from the same `OpVersions` the binary
524    /// `AGDX_HELLO` probe answers with, reading the per-surface sub-features
525    /// straight off its `features` bitset and `graph` op version. A server SHOULD
526    /// use this so its two capability carriages (the binary `features` bits and
527    /// these HTTP fields) cannot disagree: the one source drives both.
528    pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
529        use crate::hello::feature;
530        let consistency = if versions.has_feature(feature::READ_YOUR_WRITES) {
531            Consistency::ReadYourWrites
532        } else {
533            Consistency::Eventual
534        };
535        Self::new(enabled, versions)
536            .with_kv_cas(versions.has_feature(feature::KV_CAS))
537            .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
538            .with_kv_fenced_leases(versions.has_feature(feature::KV_FENCED_LEASES))
539            .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
540            .with_watch(versions.has_feature(feature::WATCH))
541            .with_sessions(enabled && versions.has_feature(feature::SESSIONS))
542            .with_authz(versions.has_feature(feature::AUTHZ))
543            .with_query_consistency(consistency)
544            // The graph surface needs a backend that serves it, advertised as a
545            // non-zero graph op version, so it is gated on that rather than implied.
546            .with_graph(enabled && versions.graph > 0)
547            .with_filters(
548                FilterCapsView::served(
549                    versions.has_feature(feature::CONSUMER_FILTERS),
550                    enabled && versions.filter > 0,
551                )
552                .with_group_policy_reads(versions.has_feature(feature::GROUP_POLICY_READS)),
553            )
554    }
555}
556
557/// One KV entry on the HTTP surface. `key` and `value` are URL-safe unpadded
558/// base64, because keys and values are arbitrary bytes that JSON strings
559/// cannot carry raw.
560#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
561#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
562pub struct KvEntryView {
563    pub key: String,
564    pub value: String,
565    pub expires_at_micros: Option<u64>,
566    /// The entry's version, the value a compare-and-swap expects.
567    pub version: u64,
568    /// The memory scope, present only on a memory read-view row (kind, agent,
569    /// user, app, conversation, the folded feedback weight, and a `SourceRef`
570    /// source pointer to the origin log record). Absent on a generic entry, so
571    /// a UI can fold a recalled item back to its source message. Skipped on the
572    /// wire when absent.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub scope: Option<crate::kv::MemoryRowScope>,
575    /// The origin log record this entry was folded from, mirroring
576    /// [`crate::kv::KvEntry::source`]: every managed write is log-first, so a
577    /// stored entry points back to the record that wrote it. Skipped when the
578    /// store did not stamp one.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub source: Option<crate::graph::SourceRef>,
581}
582
583/// One KV scan page on the HTTP surface, in key order. `page` counts from 1.
584#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
585#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
586pub struct KvPageView {
587    pub items: Vec<KvEntryView>,
588    pub page: u32,
589    pub total_pages: u32,
590    pub total_results: u32,
591}
592
593/// `DELETE /agdx/kv/{namespace}` reply: the number of entries removed.
594#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
595#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
596pub struct DeletedManyView {
597    pub deleted: usize,
598}
599
600/// One connection on the `GET /agdx/clients` discovery surface. `metadata` is
601/// URL-safe unpadded base64 of the opaque advertised bytes (a JSON string cannot
602/// carry raw bytes), or `None` when the connection advertised none. The console
603/// decodes and interprets it per producer kind (an agent card or an app blob).
604#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
605#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
606pub struct ClientMetadataView {
607    pub client_id: u32,
608    pub user_id: Option<u32>,
609    pub transport: u8,
610    pub address: String,
611    pub consumer_groups_count: u32,
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub metadata: Option<String>,
614}
615
616/// One page of the `GET /agdx/clients` discovery read. `next_cursor` is the
617/// `after` query parameter for the next page, or `None` on the last page.
618#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
619#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
620pub struct ClientMetadataListView {
621    pub clients: Vec<ClientMetadataView>,
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub next_cursor: Option<u32>,
624}
625
626/// `GET /agdx/clients` query parameters: the discovery filters and the page
627/// window, all optional. Shared by the server handler and the typed client so the
628/// query string cannot drift between them.
629#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
630#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
631pub struct ClientsQuery {
632    /// Only return connections that advertised metadata.
633    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
634    pub with_metadata_only: bool,
635    /// Only return connections authenticated as this principal.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub user_id: Option<u32>,
638    /// Page cursor: only connections with `client_id` greater than this.
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub after: Option<u32>,
641    /// Max entries per page (clamped server-side to the page cap).
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub limit: Option<u32>,
644}
645
646/// `POST /agdx/forks/{id}/promote` reply: the number of rows applied.
647#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
648#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
649pub struct PromotedView {
650    pub rows: usize,
651}
652
653/// One graph node on the HTTP surface: id as a string, its labels, and its
654/// attributes rendered as strings (e.g. the entity `value`) for a browser or
655/// wasm client that has no access to the typed `Value`.
656#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
657#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
658pub struct GraphNodeView {
659    pub id: String,
660    #[serde(default, skip_serializing_if = "Vec::is_empty")]
661    pub labels: Vec<String>,
662    #[serde(default, skip_serializing_if = "Vec::is_empty")]
663    pub attrs: Vec<(String, String)>,
664    /// The source this node was first observed in, if known. See [`SourceRef`].
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub source: Option<SourceRef>,
667}
668
669/// One graph edge on the HTTP surface: endpoint ids as strings, the type, and the
670/// weight.
671#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
672#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
673pub struct GraphEdgeView {
674    pub id: String,
675    pub from: String,
676    pub to: String,
677    pub edge_type: String,
678    pub weight: f32,
679    /// Valid-time window (epoch micros) for a bitemporal edge, omitted when open.
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub valid_from: Option<u64>,
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub valid_to: Option<u64>,
684    /// The source that most recently asserted this relationship, if known. See
685    /// [`SourceRef`].
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub source: Option<SourceRef>,
688}
689
690/// `POST /agdx/graph/{name}/query` reply: the reachable nodes, traversed edges,
691/// and (for a `paths` return) the reconstructed paths as id sequences.
692#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
693#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
694pub struct GraphResultView {
695    #[serde(default, skip_serializing_if = "Vec::is_empty")]
696    pub nodes: Vec<GraphNodeView>,
697    #[serde(default, skip_serializing_if = "Vec::is_empty")]
698    pub edges: Vec<GraphEdgeView>,
699    #[serde(default, skip_serializing_if = "Vec::is_empty")]
700    pub paths: Vec<PathView>,
701}
702
703/// One path in a `GraphResultView`: parallel node and edge id sequences, ids as
704/// Crockford-base32 strings (the JSON view of [`crate::graph::Path`]).
705#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
706#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
707pub struct PathView {
708    pub nodes: Vec<String>,
709    pub edges: Vec<String>,
710}
711
712/// `POST /agdx/schemas` body: the register request without an id. The managed
713/// backend allocates it and the reply carries it back as
714/// `{"SchemaRegistered":id}`.
715#[derive(Clone, Debug, Serialize, Deserialize)]
716#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
717pub struct RegisterSchemaBody {
718    pub source: crate::control::SchemaSource,
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub name: Option<String>,
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub version: Option<u32>,
723    /// The stream whose registry allocates the id. Absent for the
724    /// deployment-wide registry.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub stream: Option<String>,
727}
728
729/// `POST /agdx/schemas/{id}/decode` body: the record payload as URL-safe
730/// unpadded base64.
731#[derive(Clone, Debug, Serialize, Deserialize)]
732#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
733pub struct DecodeRecordBody {
734    pub payload: String,
735}
736
737/// `POST /agdx/forks` body.
738#[derive(Clone, Debug, Serialize, Deserialize)]
739#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
740pub struct ForkCreateBody {
741    pub fork_id: String,
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub parent: Option<String>,
744    #[serde(default)]
745    pub kind: ForkKind,
746    #[serde(default, skip_serializing_if = "Vec::is_empty")]
747    pub tables: Vec<String>,
748}
749
750/// `PUT /agdx/forks/{id}/rows` body. `payload_b64` is URL-safe unpadded base64,
751/// like every binary body on this surface.
752#[derive(Clone, Debug, Serialize, Deserialize)]
753#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
754pub struct ForkPutBody {
755    pub table: String,
756    pub partition_id: u32,
757    pub offset: u64,
758    #[serde(default)]
759    pub projection_id: String,
760    #[serde(default)]
761    pub projection_version: u32,
762    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
763    pub fields: BTreeMap<String, String>,
764    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
765    pub metadata: BTreeMap<String, String>,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub payload_b64: Option<String>,
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub embedding: Option<String>,
770    #[serde(default)]
771    pub tombstone: bool,
772}
773
774/// `DELETE /agdx/bindings` body: which binding to remove, by its source stream
775/// and topic. `projection_ref` absent removes the whole binding for that source.
776/// `projection_ref` present removes only that one projection from the binding,
777/// leaving the rest. Mirrors `ControlCommand::RemoveBinding`.
778#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
779#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
780pub struct RemoveBindingBody {
781    pub stream: String,
782    pub topic: String,
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub projection_ref: Option<String>,
785}
786
787/// The canonical error body every `/agdx/*` route returns on a non-2xx status.
788/// The HTTP binding's rule is "a 2xx carries the bare `Ok` payload, a failure
789/// carries this": the status line gives the coarse class (from
790/// [`ResultCode::http_status`]) and this body gives the machine-dispatchable
791/// [`ResultCode`] plus a human `message`, so a client matches on `code` instead
792/// of grepping the message text (which is for humans and may change). `detail`
793/// carries optional structured context (e.g. the conflicting version on a CAS
794/// miss) as free-form JSON.
795#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
796#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
797pub struct ErrorBody {
798    pub code: ResultCode,
799    pub message: String,
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub detail: Option<serde_json::Value>,
802}
803
804impl ErrorBody {
805    /// An error body from a classified code and a human message.
806    pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
807        Self {
808            code,
809            message: message.into(),
810            detail: None,
811        }
812    }
813
814    /// Attach structured context.
815    #[must_use]
816    pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
817        self.detail = Some(detail);
818        self
819    }
820
821    /// The HTTP status this body's `code` maps to, so a server sets the status
822    /// line and the body from one value.
823    pub fn http_status(&self) -> u16 {
824        self.code.http_status()
825    }
826}
827
828impl From<&QueryError> for ErrorBody {
829    fn from(error: &QueryError) -> Self {
830        Self::new(ResultCode::from(error), error.to_string())
831    }
832}
833
834impl From<&crate::filter::FilterError> for ErrorBody {
835    fn from(error: &crate::filter::FilterError) -> Self {
836        Self::new(error.code, error.message.clone())
837            .with_detail(serde_json::json!({ "reason": error.reason }))
838    }
839}
840
841impl From<&KvError> for ErrorBody {
842    fn from(error: &KvError) -> Self {
843        Self::new(ResultCode::from(error), error.to_string())
844    }
845}
846
847impl From<&ForkError> for ErrorBody {
848    fn from(error: &ForkError) -> Self {
849        Self::new(ResultCode::from(error), error.to_string())
850    }
851}
852
853impl From<&crate::authz::AuthzError> for ErrorBody {
854    fn from(error: &crate::authz::AuthzError) -> Self {
855        Self::new(ResultCode::from(error), error.to_string())
856    }
857}
858
859impl From<&crate::session::SessionError> for ErrorBody {
860    fn from(error: &crate::session::SessionError) -> Self {
861        Self::new(ResultCode::from(error), error.to_string())
862    }
863}
864
865/// `?topic=` on `GET /agdx/projections`: filter to bindings off this source topic.
866pub const PARAM_TOPIC: &str = "topic";
867/// `?name_contains=` on a projection or schema list: substring over the name.
868pub const PARAM_NAME_CONTAINS: &str = "name_contains";
869/// `?id_prefix=` on `GET /agdx/projections`: keep ids starting with the prefix.
870pub const PARAM_ID_PREFIX: &str = "id_prefix";
871/// `?stream=` on the schema routes: the stream whose writer-schema registry
872/// the request addresses. Absent addresses the deployment-wide registry.
873pub const PARAM_STREAM: &str = "stream";
874/// `?search=` on `GET /agdx/projections`: one substring matched against the
875/// projection name OR id. A console with a single filter box maps to it. A
876/// server matches it as `name_contains(name) OR id contains search`. Composes
877/// (AND) with the narrower `name_contains` / `id_prefix` when several are set.
878pub const PARAM_SEARCH: &str = "search";
879/// `?prefix=` on a KV scan: base64url key prefix.
880pub const PARAM_PREFIX: &str = "prefix";
881/// `?start=` on a KV scan: base64url inclusive lower bound.
882pub const PARAM_START: &str = "start";
883/// `?end=` on a KV scan: base64url exclusive upper bound.
884pub const PARAM_END: &str = "end";
885/// `?key_contains=`: base64url substring the key must contain.
886pub const PARAM_KEY_CONTAINS: &str = "key_contains";
887/// `?status=` on a session list: one session status.
888pub const PARAM_STATUS: &str = "status";
889/// `?agent=` on a session list: sessions this agent took part in.
890pub const PARAM_AGENT: &str = "agent";
891/// `?text=` on a session list: a substring of the label or id.
892pub const PARAM_TEXT: &str = "text";
893/// `?root=` on a session list: the root of the session tree.
894pub const PARAM_ROOT: &str = "root";
895/// `?label_prefix=` on a session list: the start of the label.
896pub const PARAM_LABEL_PREFIX: &str = "label_prefix";
897/// `?surface=` on session links: one link surface.
898pub const PARAM_SURFACE: &str = "surface";
899/// `?as_of=` on a session list or timeline page: the bound of a walk, the
900/// `as_of` its first page returned.
901pub const PARAM_AS_OF: &str = "as_of";
902/// `?series=` on a session list or timeline page: the change series of the
903/// walk's `as_of`.
904pub const PARAM_SERIES: &str = "series";
905/// `?page=`: the 1-based page of a list. Zero or none reads the first page.
906pub const PARAM_PAGE: &str = "page";
907/// `?results=`: the items per page of a list. Zero or none leaves it to the
908/// server.
909pub const PARAM_RESULTS: &str = "results";
910/// `?history_limit=` on session state: the most history rows to return.
911pub const PARAM_HISTORY_LIMIT: &str = "history_limit";
912/// `?expires_at_micros=` on a KV `PUT`: absolute expiry, epoch microseconds.
913pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
914/// `?expect_version=` on a KV compare-and-swap: apply only if the key holds
915/// this exact version.
916pub const PARAM_EXPECT_VERSION: &str = "expect_version";
917/// `?expect_absent=` on a KV compare-and-swap: apply only if the key is absent
918/// (create-if-absent).
919pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
920
921/// Response header on `GET /agdx/kv/{namespace}/{key}` carrying the entry's
922/// absolute expiry (epoch microseconds) as a decimal string. The value itself
923/// rides the raw response body, so this header carries the one piece of
924/// out-of-band metadata a single-key read needs. Owned here so the name is a
925/// wire constant rather than an unscoped string. Absent means no expiry.
926pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
927
928/// Response header on `GET /agdx/kv/{namespace}/{key}` carrying the entry's
929/// version as a decimal string, the value a compare-and-swap expects.
930pub const KV_VERSION_HEADER: &str = "agdx-version";
931
932/// Request header an intermediary sets on a managed HTTP write to name the end
933/// user it writes for. The streaming server forwards it to the backend, which
934/// records it beside the authenticated user and never trusts it.
935pub const ON_BEHALF_OF_HEADER: &str = "agdx-on-behalf-of";
936
937/// `GET /agdx/projections` filters. Every field is optional, and an absent field is
938/// omitted from the query string (no empty `topic=`). Field names are the
939/// `PARAM_*` consts verbatim, so the client serializer and the server parser
940/// share one spelling.
941#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
942#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
943pub struct ProjectionListQuery {
944    #[serde(default, skip_serializing_if = "Option::is_none")]
945    pub topic: Option<String>,
946    #[serde(default, skip_serializing_if = "Option::is_none")]
947    pub name_contains: Option<String>,
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub id_prefix: Option<String>,
950    /// One substring matched against the projection name OR id, for a console
951    /// with a single filter box. Composes (AND) with `name_contains`/`id_prefix`.
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub search: Option<String>,
954    /// The stream whose projections the listing covers, required in stream
955    /// tenancy.
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub stream: Option<String>,
958    /// The 1-based page. Absent or zero reads the first page.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub page: Option<u32>,
961    /// The projections per page. Absent or zero leaves it to the server.
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    pub results: Option<u32>,
964}
965
966/// `GET /agdx/schemas` filters. `name_contains` is the substring filter on a
967/// schema's optional name, the same spelling as the projection list, so the two
968/// list surfaces share one filter vocabulary.
969#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
970#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
971pub struct SchemaListQuery {
972    #[serde(default, skip_serializing_if = "Option::is_none")]
973    pub name_contains: Option<String>,
974    /// The stream whose registry is listed. Absent lists the deployment-wide
975    /// registry.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub stream: Option<String>,
978    /// The 1-based page. Absent or zero reads the first page.
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub page: Option<u32>,
981    /// The schemas per page. Absent or zero leaves it to the server.
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub results: Option<u32>,
984}
985
986/// `?stream=` on `GET /agdx/kv` and `GET /agdx/forks`: the stream whose
987/// namespaces or forks are listed, required in stream tenancy.
988#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
989#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
990pub struct StreamQuery {
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub stream: Option<String>,
993}
994
995/// `?stream=` on `GET`/`DELETE /agdx/schemas/{id}` and
996/// `POST /agdx/schemas/{id}/decode`: the stream whose registry holds the id.
997/// Absent addresses the deployment-wide registry.
998#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
999#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1000pub struct SchemaQuery {
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub stream: Option<String>,
1003}
1004
1005/// `?operation_id=` on the HTTP managed writes: the projection, binding, graph,
1006/// and schema-drop control writes, the key-value writes, and the fork writes.
1007/// It names one logical write, so a re-sent request with the same id applies
1008/// once. The value is the decimal text of a non-zero `u128`, the same text the
1009/// filter routes use. A write without it gets a fresh id per request.
1010#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1011#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1012pub struct OperationQuery {
1013    #[serde(default, skip_serializing_if = "Option::is_none")]
1014    pub operation_id: Option<String>,
1015}
1016
1017impl OperationQuery {
1018    /// The caller's operation id, or `None` when the request carries none.
1019    pub fn operation_id(&self) -> Result<Option<NonZeroU128>, InvalidOperationId> {
1020        self.operation_id
1021            .as_deref()
1022            .map(|text| {
1023                // `u128::from_str` accepts a leading `+`, which no client sends.
1024                if !text.bytes().all(|byte| byte.is_ascii_digit()) {
1025                    return Err(InvalidOperationId);
1026                }
1027                text.parse::<NonZeroU128>().map_err(|_| InvalidOperationId)
1028            })
1029            .transpose()
1030    }
1031}
1032
1033impl From<NonZeroU128> for OperationQuery {
1034    fn from(operation_id: NonZeroU128) -> Self {
1035        Self {
1036            operation_id: Some(operation_id.to_string()),
1037        }
1038    }
1039}
1040
1041/// An `?operation_id=` that is not the decimal text of a non-zero `u128`.
1042#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
1043#[error("operation_id must be a non-zero decimal u128")]
1044pub struct InvalidOperationId;
1045
1046/// `GET /agdx/filters` query parameters.
1047#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1048#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1049pub struct FilterListQuery {
1050    #[serde(default, skip_serializing_if = "Option::is_none")]
1051    pub name_contains: Option<String>,
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub state: Option<crate::filter::FilterState>,
1054    /// The owner filter is the five fields of a group identity, all or none.
1055    #[serde(default, skip_serializing_if = "Option::is_none")]
1056    pub owner_stream_id: Option<u32>,
1057    #[serde(default, skip_serializing_if = "Option::is_none")]
1058    pub owner_stream_created_at_micros: Option<u64>,
1059    #[serde(default, skip_serializing_if = "Option::is_none")]
1060    pub owner_topic_id: Option<u32>,
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub owner_topic_created_at_micros: Option<u64>,
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub owner_group_id: Option<u64>,
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub page: Option<u32>,
1067    #[serde(default, skip_serializing_if = "Option::is_none")]
1068    pub results: Option<u32>,
1069}
1070
1071impl FilterListQuery {
1072    /// The owner filter, refused unless all five identity fields are set or none.
1073    pub fn owner(&self) -> Result<Option<FilterGroupIdentity>, InvalidError> {
1074        match (
1075            self.owner_stream_id,
1076            self.owner_stream_created_at_micros,
1077            self.owner_topic_id,
1078            self.owner_topic_created_at_micros,
1079            self.owner_group_id,
1080        ) {
1081            (None, None, None, None, None) => Ok(None),
1082            (
1083                Some(stream_id),
1084                Some(stream_created_at_micros),
1085                Some(topic_id),
1086                Some(topic_created_at_micros),
1087                Some(group_id),
1088            ) => Ok(Some(FilterGroupIdentity {
1089                stream_id,
1090                stream_created_at_micros,
1091                topic_id,
1092                topic_created_at_micros,
1093                group_id,
1094            })),
1095            _ => Err(InvalidError::new(
1096                "the owner filter needs owner_stream_id, owner_stream_created_at_micros, owner_topic_id, owner_topic_created_at_micros and owner_group_id together",
1097            )),
1098        }
1099    }
1100}
1101
1102impl From<FilterGroupIdentity> for FilterListQuery {
1103    fn from(identity: FilterGroupIdentity) -> Self {
1104        Self {
1105            owner_stream_id: Some(identity.stream_id),
1106            owner_stream_created_at_micros: Some(identity.stream_created_at_micros),
1107            owner_topic_id: Some(identity.topic_id),
1108            owner_topic_created_at_micros: Some(identity.topic_created_at_micros),
1109            owner_group_id: Some(identity.group_id),
1110            ..Self::default()
1111        }
1112    }
1113}
1114
1115/// `GET /agdx/filters/{id}/revisions` query parameters.
1116#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1117#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1118pub struct FilterRevisionListQuery {
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub page: Option<u32>,
1121    #[serde(default, skip_serializing_if = "Option::is_none")]
1122    pub results: Option<u32>,
1123}
1124
1125/// `GET /agdx/filter-bindings` query parameters.
1126#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1127#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1128pub struct FilterBindingListQuery {
1129    #[serde(default, skip_serializing_if = "Option::is_none")]
1130    pub filter_id: Option<u32>,
1131    #[serde(default, skip_serializing_if = "Option::is_none")]
1132    pub stream: Option<String>,
1133    #[serde(default, skip_serializing_if = "Option::is_none")]
1134    pub topic: Option<String>,
1135    #[serde(default, skip_serializing_if = "Option::is_none")]
1136    pub page: Option<u32>,
1137    #[serde(default, skip_serializing_if = "Option::is_none")]
1138    pub results: Option<u32>,
1139}
1140
1141/// `POST /agdx/filters/test` body. The sample payload is given either as
1142/// base64url bytes or as UTF-8 text, never both.
1143#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1145pub struct FilterTestBody {
1146    pub filter: crate::filter::FilterRef,
1147    #[serde(default, skip_serializing_if = "Option::is_none")]
1148    pub payload_base64: Option<String>,
1149    #[serde(default, skip_serializing_if = "Option::is_none")]
1150    pub payload_text: Option<String>,
1151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1152    pub headers: Vec<crate::filter::FilterHeader>,
1153}
1154
1155/// `GET /agdx/kv/{namespace}` scan filters. The byte-valued bounds
1156/// (`prefix`/`start`/`end`/`key_contains`) are base64url, like every binary
1157/// value on this surface.
1158#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1159#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1160pub struct KvScanQuery {
1161    #[serde(default, skip_serializing_if = "Option::is_none")]
1162    pub prefix: Option<String>,
1163    #[serde(default, skip_serializing_if = "Option::is_none")]
1164    pub start: Option<String>,
1165    #[serde(default, skip_serializing_if = "Option::is_none")]
1166    pub end: Option<String>,
1167    #[serde(default, skip_serializing_if = "Option::is_none")]
1168    pub key_contains: Option<String>,
1169    /// The conversation lens: keep only rows the given conversation wrote (the
1170    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
1171    /// this on each record, so a scan of a memory namespace narrows to one
1172    /// conversation. Plain text, not base64url, since a conversation id is text.
1173    #[serde(default, skip_serializing_if = "Option::is_none")]
1174    pub conversation: Option<String>,
1175    /// The 1-based page. Absent or zero reads the first page.
1176    #[serde(default, skip_serializing_if = "Option::is_none")]
1177    pub page: Option<u32>,
1178    /// The entries per page. Absent or zero reads the default page size.
1179    #[serde(default, skip_serializing_if = "Option::is_none")]
1180    pub results: Option<u32>,
1181}
1182
1183/// `PUT /agdx/kv/{namespace}/{key}` query: an optional absolute expiry.
1184#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1186pub struct KvPutQuery {
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    pub expires_at_micros: Option<u64>,
1189}
1190
1191/// `GET /agdx/graph/{name}/neighbors/{node}` query: the traversal direction
1192/// (`out`, `in`, or `both`, omitted for the default `out`), an optional edge-type
1193/// filter, the hop depth (omitted for the default one hop), and a result limit
1194/// (omitted for the backend ceiling). One struct shared by the typed client and
1195/// the server route, so the two cannot drift.
1196#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1197#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1198pub struct GraphNeighborsQuery {
1199    #[serde(default, skip_serializing_if = "Option::is_none")]
1200    pub dir: Option<String>,
1201    #[serde(default, skip_serializing_if = "Option::is_none")]
1202    pub edge_type: Option<String>,
1203    #[serde(default, skip_serializing_if = "Option::is_none")]
1204    pub depth: Option<u32>,
1205    #[serde(default, skip_serializing_if = "Option::is_none")]
1206    pub limit: Option<u32>,
1207    /// Valid-time "as of" read (epoch micros): only edges valid at this instant.
1208    #[serde(default, skip_serializing_if = "Option::is_none")]
1209    pub as_of: Option<u64>,
1210    /// Restrict to elements a given conversation asserted (the text form of its
1211    /// `gen_ai.conversation.id`). Omitted reads the whole graph. The conversation
1212    /// lens over the neighbors route.
1213    #[serde(default, skip_serializing_if = "Option::is_none")]
1214    pub conversation: Option<String>,
1215}
1216
1217/// Filters and page of `GET /agdx/sessions/{stream}`. The stream is the path
1218/// segment. A `page` of zero or none reads the first page, `results` of zero
1219/// or none leaves the page size to the server, and `as_of` with `series`
1220/// bounds a walk.
1221#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1222#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1223pub struct SessionsQuery {
1224    #[serde(default, skip_serializing_if = "Option::is_none")]
1225    pub status: Option<String>,
1226    #[serde(default, skip_serializing_if = "Option::is_none")]
1227    pub root: Option<String>,
1228    #[serde(default, skip_serializing_if = "Option::is_none")]
1229    pub label_prefix: Option<String>,
1230    #[serde(default, skip_serializing_if = "Option::is_none")]
1231    pub agent: Option<String>,
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub text: Option<String>,
1234    #[serde(default, skip_serializing_if = "Option::is_none")]
1235    pub page: Option<u32>,
1236    #[serde(default, skip_serializing_if = "Option::is_none")]
1237    pub results: Option<u32>,
1238    #[serde(default, skip_serializing_if = "Option::is_none")]
1239    pub as_of: Option<u64>,
1240    #[serde(default, skip_serializing_if = "Option::is_none")]
1241    pub series: Option<u64>,
1242}
1243
1244/// Page of `GET /agdx/sessions/{stream}/{id}/events`. A `page` of zero or
1245/// none reads the first page, `results` of zero or none leaves the page size
1246/// to the server, and `as_of` with `series` bounds a walk.
1247#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1248#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1249pub struct SessionEventsQuery {
1250    #[serde(default, skip_serializing_if = "Option::is_none")]
1251    pub page: Option<u32>,
1252    #[serde(default, skip_serializing_if = "Option::is_none")]
1253    pub results: Option<u32>,
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub as_of: Option<u64>,
1256    #[serde(default, skip_serializing_if = "Option::is_none")]
1257    pub series: Option<u64>,
1258}
1259
1260/// Query of `GET /agdx/sessions/{stream}/{id}/state`. A `history_limit` of
1261/// zero or none leaves the history size to the server.
1262#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1263#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1264pub struct SessionStateQuery {
1265    #[serde(default, skip_serializing_if = "Option::is_none")]
1266    pub history_limit: Option<u32>,
1267}
1268
1269/// Query of `GET /agdx/sessions/{stream}/{id}/links`.
1270#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1271#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1272pub struct SessionLinksQuery {
1273    #[serde(default, skip_serializing_if = "Option::is_none")]
1274    pub surface: Option<String>,
1275}
1276
1277/// Page of `GET /agdx/sessions/{stream}/changes`. A `page` of zero or none
1278/// reads the first page, and `results` of zero or none leaves the page size
1279/// to the server.
1280#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1282pub struct SessionChangesQuery {
1283    #[serde(default, skip_serializing_if = "Option::is_none")]
1284    pub page: Option<u32>,
1285    #[serde(default, skip_serializing_if = "Option::is_none")]
1286    pub results: Option<u32>,
1287}
1288
1289/// `PUT /agdx/kv/{namespace}/{key}/cas` query: the compare-and-swap precondition
1290/// plus an optional expiry. Exactly one of `expect_version` (match the held
1291/// version) or `expect_absent` (create-if-absent) is set, mirroring the binary
1292/// `CasExpect`. The value rides the raw request body.
1293#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1294#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1295pub struct KvCasQuery {
1296    #[serde(default, skip_serializing_if = "Option::is_none")]
1297    pub expect_version: Option<u64>,
1298    #[serde(default, skip_serializing_if = "Option::is_none")]
1299    pub expect_absent: Option<bool>,
1300    #[serde(default, skip_serializing_if = "Option::is_none")]
1301    pub expires_at_micros: Option<u64>,
1302}
1303
1304/// `PUT /agdx/kv/{namespace}/{key}/cas` reply on success: the new version the
1305/// committed write took.
1306#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1307#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1308pub struct CasCommittedView {
1309    pub version: u64,
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::*;
1315
1316    #[test]
1317    fn given_a_filter_list_query_when_the_owner_is_complete_or_partial_then_should_resolve_or_refuse()
1318     {
1319        let identity = FilterGroupIdentity {
1320            stream_id: 1,
1321            stream_created_at_micros: 10,
1322            topic_id: 2,
1323            topic_created_at_micros: 20,
1324            group_id: 3,
1325        };
1326        let query = FilterListQuery::from(identity);
1327        assert_eq!(query.owner().expect("complete owner"), Some(identity));
1328        assert_eq!(FilterListQuery::default().owner().expect("no owner"), None);
1329        let partial = FilterListQuery {
1330            owner_group_id: None,
1331            ..query
1332        };
1333        assert!(partial.owner().is_err());
1334    }
1335
1336    #[test]
1337    fn given_path_builders_when_rendered_then_should_match_the_router() {
1338        assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1339        assert_eq!(
1340            projection_path("reading.v1"),
1341            "/agdx/projections/reading.v1"
1342        );
1343        assert_eq!(schema_path(7), "/agdx/schemas/7");
1344        assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1345        assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1346        assert_eq!(
1347            kv_entry_path("sessions", "dXNlcjo0Mg"),
1348            "/agdx/kv/sessions/dXNlcjo0Mg"
1349        );
1350        assert_eq!(
1351            kv_cas_path("sessions", "dXNlcjo0Mg"),
1352            "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1353        );
1354        assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1355        assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1356        assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1357    }
1358
1359    #[test]
1360    fn given_an_operation_id_when_rendered_and_parsed_then_should_round_trip() {
1361        let operation_id = NonZeroU128::new(u128::MAX).expect("non-zero");
1362        let query = OperationQuery::from(operation_id);
1363        assert_eq!(
1364            query.operation_id.as_deref(),
1365            Some("340282366920938463463374607431768211455")
1366        );
1367        assert_eq!(query.operation_id(), Ok(Some(operation_id)));
1368        assert_eq!(OperationQuery::default().operation_id(), Ok(None));
1369    }
1370
1371    #[test]
1372    fn given_a_malformed_operation_id_when_parsed_then_should_refuse_it() {
1373        for text in [
1374            "",
1375            "0",
1376            "+7",
1377            "-7",
1378            " 7",
1379            "0x7",
1380            "7.0",
1381            "340282366920938463463374607431768211456",
1382        ] {
1383            let query = OperationQuery {
1384                operation_id: Some(text.to_owned()),
1385            };
1386            assert_eq!(query.operation_id(), Err(InvalidOperationId), "{text:?}");
1387        }
1388    }
1389
1390    #[test]
1391    fn given_stream_scoped_names_when_rendered_then_should_encode_each_as_one_segment() {
1392        assert_eq!(
1393            kv_namespace_path("stream:acme/support"),
1394            "/agdx/kv/stream%3Aacme%2Fsupport"
1395        );
1396        assert_eq!(
1397            kv_cas_path("stream:acme/support", "dXNlcjo0Mg"),
1398            "/agdx/kv/stream%3Aacme%2Fsupport/dXNlcjo0Mg/cas"
1399        );
1400        assert_eq!(
1401            graph_neighbors_path("stream:acme/kg", "a/b"),
1402            "/agdx/graph/stream%3Aacme%2Fkg/neighbors/a%2Fb"
1403        );
1404        assert_eq!(
1405            graph_query_path("stream:acme/kg"),
1406            "/agdx/graph/stream%3Aacme%2Fkg/query"
1407        );
1408        assert_eq!(
1409            graph_path("stream:acme/kg"),
1410            "/agdx/graphs/stream%3Aacme%2Fkg"
1411        );
1412        assert_eq!(
1413            projection_path("stream:acme/readings"),
1414            "/agdx/projections/stream%3Aacme%2Freadings"
1415        );
1416    }
1417
1418    #[test]
1419    fn given_the_sessions_feature_when_built_from_versions_then_should_advertise_sessions() {
1420        let mut versions = OpVersions::new(1, 1, 1, 1);
1421        assert!(!Capabilities::from_versions(true, versions).sessions);
1422        versions.features |= crate::hello::feature::SESSIONS;
1423        assert!(Capabilities::from_versions(true, versions).sessions);
1424        assert!(!Capabilities::from_versions(false, versions).sessions);
1425    }
1426
1427    #[test]
1428    fn given_capabilities_when_constructed_then_extended_features_default_off() {
1429        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1430        assert!(
1431            caps.query.available && caps.kv.available && caps.fork,
1432            "core surfaces track enabled"
1433        );
1434        assert!(
1435            !caps.kv.cas
1436                && !caps.kv.cas_fenced
1437                && !caps.kv.fenced_leases
1438                && caps.query.consistency == Consistency::Eventual,
1439            "sub-features must be opt-in, never on by default"
1440        );
1441        let opted = caps
1442            .with_kv_cas(true)
1443            .with_query_consistency(Consistency::ReadYourWrites);
1444        assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1445    }
1446
1447    #[test]
1448    fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
1449        let versions =
1450            OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
1451        let caps = Capabilities::from_versions(true, versions);
1452        assert!(caps.kv.fenced_leases);
1453        assert!(
1454            caps.kv.cas_fenced,
1455            "the full fenced-lease contract subsumes fenced CAS"
1456        );
1457    }
1458
1459    #[test]
1460    fn given_group_reads_without_evaluation_when_building_http_capabilities_then_should_keep_catalog_available()
1461     {
1462        let versions = OpVersions::new(1, 1, 1, 1)
1463            .with_filter(crate::codes::FILTER_OP_VERSION)
1464            .with_features(crate::hello::feature::GROUP_POLICY_READS);
1465        let caps = Capabilities::from_versions(true, versions);
1466        assert!(caps.filters.group_policy_reads);
1467        assert!(caps.filters.catalog && caps.filters.managed_groups);
1468        assert!(!caps.filters.native && !caps.filters.preview);
1469        assert_eq!(caps.filters.evaluator_version, 0);
1470        assert!(caps.filters.codecs.is_empty());
1471        let absent = Capabilities::from_versions(false, OpVersions::new(1, 1, 1, 1));
1472        assert!(!absent.filters.group_policy_reads && !absent.filters.catalog);
1473    }
1474
1475    #[test]
1476    fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1477        use crate::destination::BackendResourceId;
1478        use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1479        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1480            BackendDescriptor::new(
1481                BackendResourceId::from_u128(1),
1482                BackendMode::Operational,
1483                "Embedded",
1484                BackendImplementation {
1485                    kind: "embedded".to_owned(),
1486                    version: "1.0.0".to_owned(),
1487                },
1488                1,
1489                1,
1490            ),
1491            BackendDescriptor::new(
1492                BackendResourceId::from_u128(2),
1493                BackendMode::Operational,
1494                "Warehouse",
1495                BackendImplementation {
1496                    kind: "columnar".to_owned(),
1497                    version: "1.0.0".to_owned(),
1498                },
1499                1,
1500                1,
1501            ),
1502        ]);
1503        let json = serde_json::to_string(&caps).expect("serializes");
1504        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1505        assert_eq!(back.backends.len(), 2);
1506        assert_eq!(
1507            back.backends[1].resource_id,
1508            BackendResourceId::from_u128(2)
1509        );
1510        assert_eq!(back.backends[1].implementation.kind, "columnar");
1511
1512        // No advertised backends is omitted on the wire, so a pre-backends
1513        // capabilities reply stays byte-identical.
1514        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1515        let json = serde_json::to_string(&plain).expect("json");
1516        assert!(!json.contains("backends"), "empty backends omitted: {json}");
1517    }
1518
1519    #[test]
1520    fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1521        let custom = WireTopology {
1522            ops_stream: "custom-ops".to_owned(),
1523            ..WireTopology::default()
1524        };
1525        let caps =
1526            Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1527        let json = serde_json::to_string(&caps).expect("serializes");
1528        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1529        assert_eq!(back.topology, Some(custom));
1530
1531        // No advertised topology (the default) is omitted on the wire, so a
1532        // pre-topology capabilities reply stays byte-identical.
1533        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1534        let json = serde_json::to_string(&plain).expect("json");
1535        assert!(
1536            !json.contains("topology"),
1537            "absent topology omitted: {json}"
1538        );
1539    }
1540
1541    #[test]
1542    fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1543        let body = ErrorBody::from(&QueryError::IndexNotFound("readings".to_owned()));
1544        assert_eq!(body.code, ResultCode::NotFound);
1545        assert_eq!(body.http_status(), 404);
1546        assert!(body.message.contains("readings"));
1547        // Round-trips as JSON, the form the HTTP surface serves it in.
1548        let json = serde_json::to_string(&body).expect("serializes");
1549        let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1550        assert_eq!(back, body);
1551    }
1552
1553    #[test]
1554    #[cfg(feature = "http-client")]
1555    fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1556        let query = KvScanQuery {
1557            prefix: Some("dXNlcjo".to_owned()),
1558            page: Some(2),
1559            results: Some(50),
1560            ..Default::default()
1561        };
1562        let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1563        assert_eq!(encoded, "prefix=dXNlcjo&page=2&results=50");
1564        // Field names are the PARAM_* consts verbatim.
1565        assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1566        assert!(encoded.contains(&format!("{PARAM_PAGE}=")));
1567        assert!(encoded.contains(&format!("{PARAM_RESULTS}=")));
1568    }
1569
1570    #[test]
1571    #[cfg(feature = "http-client")]
1572    fn given_names_with_reserved_characters_when_building_a_binding_path_then_should_encode_each_segment()
1573     {
1574        assert_eq!(
1575            filter_binding_path("orbit", "fleet/changes", "desk?x#1"),
1576            "/agdx/filter-bindings/orbit/fleet%2Fchanges/desk%3Fx%231"
1577        );
1578    }
1579
1580    #[cfg(feature = "http-client")]
1581    #[test]
1582    fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1583        let projections = ProjectionListQuery {
1584            name_contains: Some("reading".to_owned()),
1585            id_prefix: Some("reading.".to_owned()),
1586            ..Default::default()
1587        };
1588        let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1589        assert_eq!(encoded, "name_contains=reading&id_prefix=reading.");
1590        assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1591        assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1592
1593        let schemas = SchemaListQuery {
1594            name_contains: Some("Reading".to_owned()),
1595            ..SchemaListQuery::default()
1596        };
1597        assert_eq!(
1598            serde_urlencoded::to_string(&schemas).expect("encodes"),
1599            "name_contains=Reading"
1600        );
1601        let scoped = SchemaListQuery {
1602            stream: Some("acme".to_owned()),
1603            ..SchemaListQuery::default()
1604        };
1605        assert_eq!(
1606            serde_urlencoded::to_string(&scoped).expect("encodes"),
1607            format!("{PARAM_STREAM}=acme")
1608        );
1609        assert_eq!(
1610            serde_urlencoded::to_string(SchemaQuery::default()).expect("encodes"),
1611            ""
1612        );
1613        assert_eq!(
1614            serde_urlencoded::to_string(SchemaQuery {
1615                stream: Some("acme".to_owned()),
1616            })
1617            .expect("encodes"),
1618            format!("{PARAM_STREAM}=acme")
1619        );
1620    }
1621
1622    #[test]
1623    fn given_a_register_schema_body_without_a_stream_when_encoded_then_should_omit_it() {
1624        let body = RegisterSchemaBody {
1625            source: crate::control::SchemaSource::JsonSchema {
1626                schema: "{}".to_owned(),
1627            },
1628            name: None,
1629            version: None,
1630            stream: None,
1631        };
1632        let json = serde_json::to_string(&body).expect("encodes");
1633        assert!(
1634            !json.contains("stream"),
1635            "an unset stream is omitted: {json}"
1636        );
1637        let scoped: RegisterSchemaBody = serde_json::from_str(
1638            r#"{"source":{"kind":"json_schema","schema":"{}"},"stream":"acme"}"#,
1639        )
1640        .expect("decodes");
1641        assert_eq!(scoped.stream.as_deref(), Some("acme"));
1642    }
1643}