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