Skip to main content

laser_wire/
http.rs

1use crate::fork::{ForkError, ForkKind};
2use crate::graph::SourceRef;
3use crate::hello::{BackendDescriptor, OpVersions};
4use crate::kv::KvError;
5use crate::query::{Consistency, QueryError};
6use crate::result::ResultCode;
7use crate::topology::WireTopology;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11/// `GET /agdx/capabilities`: the feature-detection probe.
12pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
13/// `POST /agdx/query` (and `GET` with the query as a parameter).
14pub const QUERY_PATH: &str = "/agdx/query";
15/// `GET /agdx/projections` to list, `POST` to register.
16pub const PROJECTIONS_PATH: &str = "/agdx/projections";
17/// `POST /agdx/bindings` to apply, `DELETE` to remove.
18pub const BINDINGS_PATH: &str = "/agdx/bindings";
19/// `GET /agdx/schemas` to list, `POST` to register.
20pub const SCHEMAS_PATH: &str = "/agdx/schemas";
21/// `GET /agdx/kv` to list the caller's namespaces.
22pub const KV_PATH: &str = "/agdx/kv";
23/// `GET /agdx/forks` to list, `POST` to create.
24pub const FORKS_PATH: &str = "/agdx/forks";
25/// `GET /agdx/graphs` to list graph projections, `POST` to register.
26pub const GRAPHS_PATH: &str = "/agdx/graphs";
27/// `GET /agdx/clients` to list live connections with their advertised metadata,
28/// filtered and paginated by query parameters. The HTTP face of the
29/// `AGDX_GET_CLIENTS_METADATA` discovery read.
30pub const CLIENTS_PATH: &str = "/agdx/clients";
31/// `GET /agdx/runs` to list runs (filtered and paged, [`RunsQuery`]), `POST`
32/// to submit one (a JSON `AgentSubmit` body). The HTTP face of the
33/// `AGDX_AGENT_*` run-registry band.
34pub const RUNS_PATH: &str = "/agdx/runs";
35/// `GET /agdx/authz/whoami`: the caller's effective governance roles and grants.
36pub const AUTHZ_WHOAMI_PATH: &str = "/agdx/authz/whoami";
37/// `GET /agdx/authz/roles`: list governance roles.
38pub const AUTHZ_ROLES_PATH: &str = "/agdx/authz/roles";
39
40/// `GET`/`PUT`/`DELETE /agdx/authz/roles/{name}`.
41pub fn authz_role_path(name: &str) -> String {
42    format!("{AUTHZ_ROLES_PATH}/{name}")
43}
44
45/// `GET`/`PUT /agdx/authz/users/{id}/roles`: read or replace one user's role set.
46pub fn authz_user_roles_path(user_id: u32) -> String {
47    format!("/agdx/authz/users/{user_id}/roles")
48}
49
50/// `DELETE`/`GET /agdx/graphs/{id}`: drop or read a graph projection.
51pub fn graph_path(id: &str) -> String {
52    format!("{GRAPHS_PATH}/{id}")
53}
54
55/// `POST /agdx/graph/{name}/query`: run a traversal (a `GraphQuery` body).
56pub fn graph_query_path(name: &str) -> String {
57    format!("/agdx/graph/{name}/query")
58}
59
60/// `GET /agdx/graph/{name}/neighbors/{node}`: one-hop neighbor read.
61pub fn graph_neighbors_path(name: &str, node: &str) -> String {
62    format!("/agdx/graph/{name}/neighbors/{node}")
63}
64
65/// `GET`/`DELETE /agdx/projections/{id}`.
66pub fn projection_path(id: &str) -> String {
67    format!("{PROJECTIONS_PATH}/{id}")
68}
69
70/// `GET`/`DELETE /agdx/schemas/{id}`.
71pub fn schema_path(id: u32) -> String {
72    format!("{SCHEMAS_PATH}/{id}")
73}
74
75/// `POST /agdx/schemas/{id}/decode`.
76pub fn schema_decode_path(id: u32) -> String {
77    format!("{SCHEMAS_PATH}/{id}/decode")
78}
79
80/// `GET /agdx/kv/{namespace}` to scan, `DELETE` to bulk-delete.
81pub fn kv_namespace_path(namespace: &str) -> String {
82    format!("{KV_PATH}/{namespace}")
83}
84
85/// `GET`/`PUT`/`DELETE /agdx/kv/{namespace}/{key}`. `key` is the URL-safe
86/// unpadded base64 form of the key bytes, the encoding this surface uses for
87/// every binary body.
88pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
89    format!("{KV_PATH}/{namespace}/{key_b64}")
90}
91
92/// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write (compare-and-swap).
93/// The precondition rides the query string (`expect_version` or `expect_absent`)
94/// and the value rides the raw body, like the plain `PUT`. A success replies
95/// `CasCommittedView` with the new version, a precondition miss replies `409`
96/// with an `ErrorBody` of code `conflict` whose `detail` carries the current
97/// version.
98pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
99    format!("{KV_PATH}/{namespace}/{key_b64}/cas")
100}
101
102/// `DELETE /agdx/forks/{id}`.
103pub fn fork_path(id: &str) -> String {
104    format!("{FORKS_PATH}/{id}")
105}
106
107/// `POST /agdx/forks/{id}/promote`.
108pub fn fork_promote_path(id: &str) -> String {
109    format!("{FORKS_PATH}/{id}/promote")
110}
111
112/// `PUT /agdx/forks/{id}/rows`.
113pub fn fork_rows_path(id: &str) -> String {
114    format!("{FORKS_PATH}/{id}/rows")
115}
116
117/// `GET /agdx/runs/{id}`: read one run's status.
118pub fn run_path(id: &str) -> String {
119    format!("{RUNS_PATH}/{id}")
120}
121
122/// `POST /agdx/runs/{id}/cancel`: record the cancel intent on a run.
123pub fn run_cancel_path(id: &str) -> String {
124    format!("{RUNS_PATH}/{id}/cancel")
125}
126
127/// `GET /agdx/capabilities` reply: what the `/agdx/*` surface offers on this
128/// server, so a browser client can feature-detect before showing the
129/// projections / query / KV / fork views. Richer than the binary `AGDX_HELLO`
130/// probe (per-surface flags plus the wire op versions the JSON bodies must
131/// match), and it answers truthfully even when the managed backend is disabled (200
132/// with `managed: false`).
133#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134#[non_exhaustive]
135pub struct Capabilities {
136    /// Connected to a managed plane at all (the root: with no plane every managed
137    /// surface below is off, and the reply still answers `200` with `managed:
138    /// false`).
139    pub managed: bool,
140    /// The managed query surface, its registry browse views, and the strongest
141    /// read-consistency it serves.
142    pub query: QueryCapsView,
143    /// The managed key-value surface and its conditional-write support.
144    pub kv: KvCapsView,
145    /// Whether the knowledge-graph ops (traversal, neighbors) are served. The
146    /// agentic-memory API composes the query and graph surfaces, so it has no
147    /// flag of its own: a client reads `query` and `graph`.
148    #[serde(default)]
149    pub graph: bool,
150    /// Whether copy-on-write forks are served.
151    pub fork: bool,
152    /// Whether the agent and workflow control band is served. Off until the plane
153    /// serves it (the engine is a later phase).
154    #[serde(default)]
155    pub agent_workflow: bool,
156    /// Whether the change feed is published (one change record per committed
157    /// notifying projector batch on the changes topic). Off by default: a
158    /// client that waits on an unpublished feed would wait forever.
159    #[serde(default)]
160    pub watch: bool,
161    /// Whether the authorization control band (`AGDX_AUTHZ_*`) is served, so a
162    /// console can show its roles and bindings surface. Off by default.
163    #[serde(default)]
164    pub authz: bool,
165    pub versions: OpVersions,
166    /// Materialization backends the server currently exposes, so a client can
167    /// show what it may route to. Identity only (id + engine kind), no settings
168    /// or secrets. Empty (the default) is skipped on encode, so a pre-backends
169    /// capabilities reply stays byte-identical.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub backends: Vec<BackendDescriptor>,
172    /// The stream/topic names this deployment uses, mirroring
173    /// [`crate::hello::BackendAnnounce::topology`]. Absent (the default) is
174    /// skipped on encode, so a pre-topology capabilities reply stays
175    /// byte-identical and an older reader falls back to
176    /// [`WireTopology::default`].
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub topology: Option<WireTopology>,
179}
180
181/// The managed query surface on the HTTP capabilities reply: whether it is
182/// served, its registry browse views, and the consistency it honors.
183#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
184pub struct QueryCapsView {
185    /// Whether `POST /agdx/query` is served.
186    pub available: bool,
187    /// Whether the projection registry browse routes are served.
188    pub projections: bool,
189    /// Whether the schema registry browse routes are served.
190    pub schemas: bool,
191    /// The strongest read-consistency the surface serves (the ladder
192    /// `eventual < read_your_writes < strong`, so a level implies the weaker
193    /// ones). Defaults to `eventual`, which every query surface serves.
194    #[serde(default)]
195    pub consistency: Consistency,
196    /// Whether lexical relevance search (`Query.text`) is served. Defaults
197    /// off, like every sub-feature: over-advertising would turn the clean
198    /// unsupported into a silent wrong answer.
199    #[serde(default)]
200    pub keyword: bool,
201}
202
203/// The managed key-value surface on the HTTP capabilities reply.
204#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
205pub struct KvCapsView {
206    /// Whether the get/set/scan routes are served.
207    pub available: bool,
208    /// Whether compare-and-swap (`AGDX_KV_CAS`) is served. Independent of plain
209    /// get/set: a backend that cannot do a conditional write leaves it off and a
210    /// `cas` returns a clean unsupported error.
211    #[serde(default)]
212    pub cas: bool,
213    /// Whether fenced compare-and-swap (`AGDX_KV_CAS_FENCED`) is served. Independent
214    /// of plain `cas`: a backend leaves it off when it cannot gate a write on a live
215    /// fence sequence.
216    #[serde(default)]
217    pub cas_fenced: bool,
218}
219
220impl Capabilities {
221    /// Constructor for the non-exhaustive wire struct. The core surfaces track
222    /// `enabled`, the way the binary `AGDX_HELLO` probe answers. The per-surface
223    /// sub-features (`kv.cas`, `query.consistency` above `eventual`, `graph`) all
224    /// start off: a server must opt into each, never over-advertising (which would
225    /// turn a clean unsupported error into a silent wrong answer). Build them with
226    /// [`from_versions`](Self::from_versions) or the setters.
227    pub fn new(enabled: bool, versions: OpVersions) -> Self {
228        Self {
229            managed: enabled,
230            query: QueryCapsView {
231                available: enabled,
232                projections: enabled,
233                schemas: enabled,
234                consistency: Consistency::Eventual,
235                keyword: false,
236            },
237            kv: KvCapsView {
238                available: enabled,
239                cas: false,
240                cas_fenced: false,
241            },
242            graph: false,
243            fork: enabled,
244            agent_workflow: false,
245            watch: false,
246            authz: false,
247            versions,
248            backends: Vec::new(),
249            topology: None,
250        }
251    }
252
253    /// Advertise that the knowledge-graph ops are served. Off by default: a
254    /// server sets it only when a backend implements the graph surface.
255    #[must_use]
256    pub fn with_graph(mut self, value: bool) -> Self {
257        self.graph = value;
258        self
259    }
260
261    /// Advertise that the agent and workflow control band is served. Off by
262    /// default: a server sets it only when it serves the band.
263    #[must_use]
264    pub fn with_agent_workflow(mut self, value: bool) -> Self {
265        self.agent_workflow = value;
266        self
267    }
268
269    /// Set whether lexical keyword search is served on the query surface.
270    #[must_use]
271    pub fn with_query_keyword(mut self, value: bool) -> Self {
272        self.query.keyword = value;
273        self
274    }
275
276    /// Advertise that the change feed is published. Only a deployment whose
277    /// projector emits change records may set it.
278    #[must_use]
279    pub fn with_watch(mut self, value: bool) -> Self {
280        self.watch = value;
281        self
282    }
283
284    /// Advertise that the authorization control band is served.
285    #[must_use]
286    pub fn with_authz(mut self, value: bool) -> Self {
287        self.authz = value;
288        self
289    }
290
291    /// Advertise the materialization backends the server exposes. The wire pins
292    /// no engine, so a server lists whatever it has open by id and kind.
293    #[must_use]
294    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
295        self.backends = backends;
296        self
297    }
298
299    /// Advertise the deployment's `topology`, mirroring
300    /// [`crate::hello::BackendAnnounce::with_topology`].
301    #[must_use]
302    pub fn with_topology(mut self, topology: WireTopology) -> Self {
303        self.topology = Some(topology);
304        self
305    }
306
307    /// Advertise compare-and-swap on the KV surface (`AGDX_KV_CAS`). Only a
308    /// backend that does a genuine conditional write may set it.
309    #[must_use]
310    pub fn with_kv_cas(mut self, on: bool) -> Self {
311        self.kv.cas = on;
312        self
313    }
314
315    /// Advertise fenced compare-and-swap on the KV surface (`AGDX_KV_CAS_FENCED`).
316    /// Only a backend that gates a write on a live fence sequence may set it.
317    #[must_use]
318    pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
319        self.kv.cas_fenced = on;
320        self
321    }
322
323    /// Advertise the strongest read-consistency the query surface serves.
324    #[must_use]
325    pub fn with_query_consistency(mut self, level: Consistency) -> Self {
326        self.query.consistency = level;
327        self
328    }
329
330    /// Build the HTTP capabilities from the same `OpVersions` the binary
331    /// `AGDX_HELLO` probe answers with, reading the per-surface sub-features
332    /// straight off its `features` bitset and `graph` op version. A server SHOULD
333    /// use this so its two capability carriages (the binary `features` bits and
334    /// these HTTP fields) cannot disagree: the one source drives both.
335    pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
336        use crate::hello::feature;
337        let consistency = if versions.has_feature(feature::STRONG_CONSISTENCY) {
338            Consistency::Strong
339        } else if versions.has_feature(feature::READ_YOUR_WRITES) {
340            Consistency::ReadYourWrites
341        } else {
342            Consistency::Eventual
343        };
344        Self::new(enabled, versions)
345            .with_kv_cas(versions.has_feature(feature::KV_CAS))
346            .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
347            .with_agent_workflow(versions.has_feature(feature::AGENT_WORKFLOW))
348            .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
349            .with_watch(versions.has_feature(feature::WATCH))
350            .with_authz(versions.has_feature(feature::AUTHZ))
351            .with_query_consistency(consistency)
352            // The graph surface needs a backend that serves it, advertised as a
353            // non-zero graph op version, so it is gated on that rather than implied.
354            .with_graph(enabled && versions.graph > 0)
355    }
356}
357
358/// One KV entry on the HTTP surface. `key` and `value` are URL-safe unpadded
359/// base64, because keys and values are arbitrary bytes that JSON strings
360/// cannot carry raw.
361#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
362pub struct KvEntryView {
363    pub key: String,
364    pub value: String,
365    pub expires_at_micros: Option<u64>,
366    /// The memory scope, present only on a memory read-view row (kind, agent,
367    /// user, app, conversation, and a `SourceRef` source pointer to the origin
368    /// log record). Absent on a generic entry, so a UI can fold a recalled item
369    /// back to its source message. Skipped on the wire when absent.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub scope: Option<crate::kv::MemoryRowScope>,
372    /// The origin log record this entry was folded from, mirroring
373    /// [`crate::kv::KvEntry::source`]: every managed write is log-first, so a
374    /// stored entry points back to the record that wrote it. Skipped when the
375    /// store did not stamp one.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub source: Option<crate::graph::SourceRef>,
378}
379
380/// One KV scan page on the HTTP surface.
381#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
382pub struct KvPageView {
383    pub entries: Vec<KvEntryView>,
384    pub cursor: Option<String>,
385}
386
387/// One page of runs on the HTTP surface: the rows (the binary `AgentRunInfo`
388/// is already JSON-safe) plus the next-page cursor as URL-safe unpadded
389/// base64, like every binary value on this surface. Absent cursor means the
390/// last page.
391#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
392pub struct RunPageView {
393    pub runs: Vec<crate::agent_workflow::AgentRunInfo>,
394    pub cursor: Option<String>,
395}
396
397/// `DELETE /agdx/kv/{namespace}` reply: the number of entries removed.
398#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
399pub struct DeletedManyView {
400    pub deleted: usize,
401}
402
403/// One connection on the `GET /agdx/clients` discovery surface. `metadata` is
404/// URL-safe unpadded base64 of the opaque advertised bytes (a JSON string cannot
405/// carry raw bytes), or `None` when the connection advertised none. The console
406/// decodes and interprets it per producer kind (an agent card or an app blob).
407#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
408pub struct ClientMetadataView {
409    pub client_id: u32,
410    pub user_id: Option<u32>,
411    pub transport: u8,
412    pub address: String,
413    pub consumer_groups_count: u32,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub metadata: Option<String>,
416}
417
418/// One page of the `GET /agdx/clients` discovery read. `next_cursor` is the
419/// `after` query parameter for the next page, or `None` on the last page.
420#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
421pub struct ClientMetadataListView {
422    pub clients: Vec<ClientMetadataView>,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub next_cursor: Option<u32>,
425}
426
427/// `GET /agdx/clients` query parameters: the discovery filters and the page
428/// window, all optional. Shared by the server handler and the typed client so the
429/// query string cannot drift between them.
430#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
431pub struct ClientsQuery {
432    /// Only return connections that advertised metadata.
433    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
434    pub with_metadata_only: bool,
435    /// Only return connections authenticated as this principal.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub user_id: Option<u32>,
438    /// Page cursor: only connections with `client_id` greater than this.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub after: Option<u32>,
441    /// Max entries per page (clamped server-side to the page cap).
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub limit: Option<u32>,
444}
445
446/// `POST /agdx/forks/{id}/promote` reply: the number of rows applied.
447#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
448pub struct PromotedView {
449    pub rows: usize,
450}
451
452/// One graph node on the HTTP surface: id as a string, its labels, and its
453/// attributes rendered as strings (e.g. the entity `value`) for a browser or
454/// wasm client that has no access to the typed `Value`.
455#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
456pub struct GraphNodeView {
457    pub id: String,
458    #[serde(default, skip_serializing_if = "Vec::is_empty")]
459    pub labels: Vec<String>,
460    #[serde(default, skip_serializing_if = "Vec::is_empty")]
461    pub attrs: Vec<(String, String)>,
462    /// The source this node was first observed in, if known. See [`SourceRef`].
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub source: Option<SourceRef>,
465}
466
467/// One graph edge on the HTTP surface: endpoint ids as strings, the type, and the
468/// weight.
469#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
470pub struct GraphEdgeView {
471    pub id: String,
472    pub from: String,
473    pub to: String,
474    pub edge_type: String,
475    pub weight: f32,
476    /// Valid-time window (epoch micros) for a bitemporal edge, omitted when open.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub valid_from: Option<u64>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub valid_to: Option<u64>,
481    /// The source that most recently asserted this relationship, if known. See
482    /// [`SourceRef`].
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub source: Option<SourceRef>,
485}
486
487/// `POST /agdx/graph/{name}/query` reply: the reachable nodes, traversed edges,
488/// and (for a `paths` return) the reconstructed paths as id sequences.
489#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
490pub struct GraphResultView {
491    #[serde(default, skip_serializing_if = "Vec::is_empty")]
492    pub nodes: Vec<GraphNodeView>,
493    #[serde(default, skip_serializing_if = "Vec::is_empty")]
494    pub edges: Vec<GraphEdgeView>,
495    #[serde(default, skip_serializing_if = "Vec::is_empty")]
496    pub paths: Vec<PathView>,
497}
498
499/// One path in a `GraphResultView`: parallel node and edge id sequences, ids as
500/// Crockford-base32 strings (the JSON view of [`crate::graph::Path`]).
501#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
502pub struct PathView {
503    pub nodes: Vec<String>,
504    pub edges: Vec<String>,
505}
506
507/// `POST /agdx/schemas` body: the register request without an id. The managed
508/// backend allocates it and the reply carries it back as
509/// `{"SchemaRegistered":id}`.
510#[derive(Clone, Debug, Serialize, Deserialize)]
511pub struct RegisterSchemaBody {
512    pub source: crate::control::SchemaSource,
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub name: Option<String>,
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub version: Option<u32>,
517}
518
519/// `POST /agdx/schemas/{id}/decode` body: the record payload as URL-safe
520/// unpadded base64.
521#[derive(Clone, Debug, Serialize, Deserialize)]
522pub struct DecodeRecordBody {
523    pub payload: String,
524}
525
526/// `POST /agdx/forks` body.
527#[derive(Clone, Debug, Serialize, Deserialize)]
528pub struct ForkCreateBody {
529    pub fork_id: String,
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub parent: Option<String>,
532    #[serde(default)]
533    pub kind: ForkKind,
534    #[serde(default, skip_serializing_if = "Vec::is_empty")]
535    pub tables: Vec<String>,
536}
537
538/// `PUT /agdx/forks/{id}/rows` body. `payload_b64` is URL-safe unpadded base64,
539/// like every binary body on this surface.
540#[derive(Clone, Debug, Serialize, Deserialize)]
541pub struct ForkPutBody {
542    pub table: String,
543    pub partition_id: u32,
544    pub offset: u64,
545    #[serde(default)]
546    pub projection_id: String,
547    #[serde(default)]
548    pub projection_version: u32,
549    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
550    pub fields: BTreeMap<String, String>,
551    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
552    pub metadata: BTreeMap<String, String>,
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub payload_b64: Option<String>,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub embedding: Option<String>,
557    #[serde(default)]
558    pub tombstone: bool,
559}
560
561/// `DELETE /agdx/bindings` body: which binding to remove, by its source stream
562/// and topic. `projection_ref` absent removes the whole binding for that source.
563/// `projection_ref` present removes only that one projection from the binding,
564/// leaving the rest. Mirrors `ControlCommand::RemoveBinding`.
565#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
566pub struct RemoveBindingBody {
567    pub stream: String,
568    pub topic: String,
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub projection_ref: Option<String>,
571}
572
573/// The canonical error body every `/agdx/*` route returns on a non-2xx status.
574/// The HTTP binding's rule is "a 2xx carries the bare `Ok` payload, a failure
575/// carries this": the status line gives the coarse class (from
576/// [`ResultCode::http_status`]) and this body gives the machine-dispatchable
577/// [`ResultCode`] plus a human `message`, so a client matches on `code` instead
578/// of grepping the message text (which is for humans and may change). `detail`
579/// carries optional structured context (e.g. the conflicting version on a CAS
580/// miss) as free-form JSON.
581#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
582pub struct ErrorBody {
583    pub code: ResultCode,
584    pub message: String,
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub detail: Option<serde_json::Value>,
587}
588
589impl ErrorBody {
590    /// An error body from a classified code and a human message.
591    pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
592        Self {
593            code,
594            message: message.into(),
595            detail: None,
596        }
597    }
598
599    /// Attach structured context.
600    #[must_use]
601    pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
602        self.detail = Some(detail);
603        self
604    }
605
606    /// The HTTP status this body's `code` maps to, so a server sets the status
607    /// line and the body from one value.
608    pub fn http_status(&self) -> u16 {
609        self.code.http_status()
610    }
611}
612
613impl From<&QueryError> for ErrorBody {
614    fn from(error: &QueryError) -> Self {
615        Self::new(ResultCode::from(error), error.to_string())
616    }
617}
618
619impl From<&KvError> for ErrorBody {
620    fn from(error: &KvError) -> Self {
621        Self::new(ResultCode::from(error), error.to_string())
622    }
623}
624
625impl From<&ForkError> for ErrorBody {
626    fn from(error: &ForkError) -> Self {
627        Self::new(ResultCode::from(error), error.to_string())
628    }
629}
630
631impl From<&crate::agent_workflow::AgentError> for ErrorBody {
632    fn from(error: &crate::agent_workflow::AgentError) -> Self {
633        Self::new(ResultCode::from(error), error.to_string())
634    }
635}
636
637/// `?topic=` on `GET /agdx/projections`: filter to bindings off this source topic.
638pub const PARAM_TOPIC: &str = "topic";
639/// `?name_contains=` on a projection or schema list: substring over the name.
640pub const PARAM_NAME_CONTAINS: &str = "name_contains";
641/// `?id_prefix=` on `GET /agdx/projections`: keep ids starting with the prefix.
642pub const PARAM_ID_PREFIX: &str = "id_prefix";
643/// `?search=` on `GET /agdx/projections`: one substring matched against the
644/// projection name OR id. A console with a single filter box maps to it. A
645/// server matches it as `name_contains(name) OR id contains search`. Composes
646/// (AND) with the narrower `name_contains` / `id_prefix` when several are set.
647pub const PARAM_SEARCH: &str = "search";
648/// `?prefix=` on a KV scan: base64url key prefix.
649pub const PARAM_PREFIX: &str = "prefix";
650/// `?start=` on a KV scan: base64url inclusive lower bound.
651pub const PARAM_START: &str = "start";
652/// `?end=` on a KV scan: base64url exclusive upper bound.
653pub const PARAM_END: &str = "end";
654/// `?key_contains=`: base64url substring the key must contain.
655pub const PARAM_KEY_CONTAINS: &str = "key_contains";
656/// `?limit=`: page size.
657pub const PARAM_LIMIT: &str = "limit";
658/// `?cursor=`: opaque continuation token from the prior page.
659pub const PARAM_CURSOR: &str = "cursor";
660/// `?expires_at_micros=` on a KV `PUT`: absolute expiry, epoch microseconds.
661pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
662/// `?expect_version=` on a KV compare-and-swap: apply only if the key holds
663/// this exact version.
664pub const PARAM_EXPECT_VERSION: &str = "expect_version";
665/// `?expect_absent=` on a KV compare-and-swap: apply only if the key is absent
666/// (create-if-absent).
667pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
668
669/// Response header on `GET /agdx/kv/{namespace}/{key}` carrying the entry's
670/// absolute expiry (epoch microseconds) as a decimal string. The value itself
671/// rides the raw response body, so this header carries the one piece of
672/// out-of-band metadata a single-key read needs. Owned here so the name is a
673/// wire constant rather than an unscoped string. Absent means no expiry.
674pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
675
676/// `GET /agdx/projections` filters. Every field is optional, and an absent field is
677/// omitted from the query string (no empty `topic=`). Field names are the
678/// `PARAM_*` consts verbatim, so the client serializer and the server parser
679/// share one spelling.
680#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
681pub struct ProjectionListQuery {
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub topic: Option<String>,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub name_contains: Option<String>,
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub id_prefix: Option<String>,
688    /// One substring matched against the projection name OR id, for a console
689    /// with a single filter box. Composes (AND) with `name_contains`/`id_prefix`.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub search: Option<String>,
692}
693
694/// `GET /agdx/schemas` filters. `name_contains` is the substring filter on a
695/// schema's optional name, the same spelling as the projection list, so the two
696/// list surfaces share one filter vocabulary.
697#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
698pub struct SchemaListQuery {
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub name_contains: Option<String>,
701}
702
703/// `GET /agdx/kv/{namespace}` scan filters. The byte-valued bounds
704/// (`prefix`/`start`/`end`/`key_contains`) are base64url, like every binary
705/// value on this surface.
706#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
707pub struct KvScanQuery {
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub prefix: Option<String>,
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub start: Option<String>,
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub end: Option<String>,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub key_contains: Option<String>,
716    /// The conversation lens: keep only rows the given conversation wrote (the
717    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
718    /// this on each record, so a scan of a memory namespace narrows to one
719    /// conversation. Plain text, not base64url, since a conversation id is text.
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub conversation: Option<String>,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub limit: Option<usize>,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub cursor: Option<String>,
726}
727
728/// `PUT /agdx/kv/{namespace}/{key}` query: an optional absolute expiry.
729#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
730pub struct KvPutQuery {
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub expires_at_micros: Option<u64>,
733}
734
735/// `GET /agdx/graph/{name}/neighbors/{node}` query: the traversal direction
736/// (`out`, `in`, or `both`, omitted for the default `out`), an optional edge-type
737/// filter, the hop depth (omitted for the default one hop), and a result limit
738/// (omitted for the backend ceiling). One struct shared by the typed client and
739/// the server route, so the two cannot drift.
740#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
741pub struct GraphNeighborsQuery {
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub dir: Option<String>,
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub edge_type: Option<String>,
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub depth: Option<u32>,
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub limit: Option<usize>,
750    /// Valid-time "as of" read (epoch micros): only edges valid at this instant.
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub as_of: Option<u64>,
753    /// Restrict to elements a given conversation asserted (the text form of its
754    /// `gen_ai.conversation.id`). Omitted reads the whole graph. The conversation
755    /// lens over the neighbors route.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub conversation: Option<String>,
758}
759
760/// `GET /agdx/runs` filters: the binary `AgentList` rendered as query
761/// parameters. `state` is the snake-case
762/// [`AgentRunState`](crate::agent_workflow::AgentRunState) word, `cursor` is
763/// the base64url form of the opaque page cursor, like every binary value on
764/// this surface. One struct shared by the typed client and the server route,
765/// so the two cannot drift.
766#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
767pub struct RunsQuery {
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub agent_id: Option<String>,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub state: Option<String>,
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub limit: Option<u32>,
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub cursor: Option<String>,
776}
777
778/// `PUT /agdx/kv/{namespace}/{key}/cas` query: the compare-and-swap precondition
779/// plus an optional expiry. Exactly one of `expect_version` (match the held
780/// version) or `expect_absent` (create-if-absent) is set, mirroring the binary
781/// `CasExpect`. The value rides the raw request body.
782#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
783pub struct KvCasQuery {
784    #[serde(default, skip_serializing_if = "Option::is_none")]
785    pub expect_version: Option<u64>,
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub expect_absent: Option<bool>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub expires_at_micros: Option<u64>,
790}
791
792/// `PUT /agdx/kv/{namespace}/{key}/cas` reply on success: the new version the
793/// committed write took.
794#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
795pub struct CasCommittedView {
796    pub version: u64,
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    #[test]
804    fn given_path_builders_when_rendered_then_should_match_the_router() {
805        assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
806        assert_eq!(projection_path("order.v1"), "/agdx/projections/order.v1");
807        assert_eq!(schema_path(7), "/agdx/schemas/7");
808        assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
809        assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
810        assert_eq!(
811            kv_entry_path("sessions", "dXNlcjo0Mg"),
812            "/agdx/kv/sessions/dXNlcjo0Mg"
813        );
814        assert_eq!(
815            kv_cas_path("sessions", "dXNlcjo0Mg"),
816            "/agdx/kv/sessions/dXNlcjo0Mg/cas"
817        );
818        assert_eq!(fork_path("f1"), "/agdx/forks/f1");
819        assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
820        assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
821    }
822
823    #[test]
824    fn given_capabilities_when_constructed_then_extended_features_default_off() {
825        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
826        assert!(
827            caps.query.available && caps.kv.available && caps.fork,
828            "core surfaces track enabled"
829        );
830        assert!(
831            !caps.kv.cas && caps.query.consistency == Consistency::Eventual,
832            "sub-features must be opt-in, never on by default"
833        );
834        let opted = caps
835            .with_kv_cas(true)
836            .with_query_consistency(Consistency::ReadYourWrites);
837        assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
838    }
839
840    #[test]
841    fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
842        use crate::hello::BackendDescriptor;
843        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
844            BackendDescriptor::new("embedded", "embedded"),
845            BackendDescriptor::new("warehouse", "columnar"),
846        ]);
847        let json = serde_json::to_string(&caps).expect("serializes");
848        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
849        assert_eq!(back.backends.len(), 2);
850        assert_eq!(back.backends[1].id, "warehouse");
851        assert_eq!(back.backends[1].kind, "columnar");
852
853        // No advertised backends is omitted on the wire, so a pre-backends
854        // capabilities reply stays byte-identical.
855        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
856        let json = serde_json::to_string(&plain).expect("json");
857        assert!(!json.contains("backends"), "empty backends omitted: {json}");
858    }
859
860    #[test]
861    fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
862        let custom = WireTopology {
863            ops_stream: "custom-ops".to_owned(),
864            ..WireTopology::default()
865        };
866        let caps =
867            Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
868        let json = serde_json::to_string(&caps).expect("serializes");
869        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
870        assert_eq!(back.topology, Some(custom));
871
872        // No advertised topology (the default) is omitted on the wire, so a
873        // pre-topology capabilities reply stays byte-identical.
874        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
875        let json = serde_json::to_string(&plain).expect("json");
876        assert!(
877            !json.contains("topology"),
878            "absent topology omitted: {json}"
879        );
880    }
881
882    #[test]
883    fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
884        let body = ErrorBody::from(&QueryError::IndexNotFound("orders".to_owned()));
885        assert_eq!(body.code, ResultCode::NotFound);
886        assert_eq!(body.http_status(), 404);
887        assert!(body.message.contains("orders"));
888        // Round-trips as JSON, the form the HTTP surface serves it in.
889        let json = serde_json::to_string(&body).expect("serializes");
890        let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
891        assert_eq!(back, body);
892    }
893
894    #[test]
895    #[cfg(feature = "http-client")]
896    fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
897        let query = KvScanQuery {
898            prefix: Some("dXNlcjo".to_owned()),
899            limit: Some(50),
900            ..Default::default()
901        };
902        let encoded = serde_urlencoded::to_string(&query).expect("encodes");
903        assert_eq!(encoded, "prefix=dXNlcjo&limit=50");
904        // Field names are the PARAM_* consts verbatim.
905        assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
906        assert!(encoded.contains(&format!("{PARAM_LIMIT}=")));
907    }
908
909    #[test]
910    #[cfg(feature = "http-client")]
911    fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
912        let projections = ProjectionListQuery {
913            name_contains: Some("order".to_owned()),
914            id_prefix: Some("order.".to_owned()),
915            ..Default::default()
916        };
917        let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
918        assert_eq!(encoded, "name_contains=order&id_prefix=order.");
919        assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
920        assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
921
922        let schemas = SchemaListQuery {
923            name_contains: Some("Order".to_owned()),
924        };
925        assert_eq!(
926            serde_urlencoded::to_string(&schemas).expect("encodes"),
927            "name_contains=Order"
928        );
929    }
930}