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