Skip to main content

laser_wire/
http.rs

1use crate::checkpoint::{
2    CheckpointRequestEnvelope, DestinationCheckpointStatus, PreparedAttemptSummary, RetentionGap,
3};
4use crate::destination::{
5    DestinationId, DestinationOperationId, MaterializationDestination, QueryRoute,
6};
7use crate::fork::{ForkError, ForkKind};
8use crate::graph::SourceRef;
9use crate::hello::{BackendDescriptor, OpVersions};
10use crate::kv::KvError;
11use crate::query::{Consistency, QueryError, QueryExecutionId, QueryExecutionStatus, QueryResult};
12use crate::result::ResultCode;
13use crate::schema::{Digest32, LogicalSchema, TypedValue, UuidValue};
14use crate::topology::WireTopology;
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18/// `GET /agdx/capabilities`: the feature-detection probe.
19pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
20/// `POST /agdx/query` (and `GET` with the query as a parameter).
21pub const QUERY_PATH: &str = "/agdx/query";
22/// `GET` and `POST /agdx/destinations`.
23pub const DESTINATIONS_PATH: &str = "/agdx/destinations";
24/// `GET /agdx/query-routes`.
25pub const QUERY_ROUTES_PATH: &str = "/agdx/query-routes";
26/// `GET /agdx/projections` to list, `POST` to register.
27pub const PROJECTIONS_PATH: &str = "/agdx/projections";
28/// `POST /agdx/bindings` to apply, `DELETE` to remove.
29pub const BINDINGS_PATH: &str = "/agdx/bindings";
30/// `GET /agdx/schemas` to list, `POST` to register.
31pub const SCHEMAS_PATH: &str = "/agdx/schemas";
32/// `GET /agdx/kv` to list the caller's namespaces.
33pub const KV_PATH: &str = "/agdx/kv";
34/// `GET /agdx/forks` to list, `POST` to create.
35pub const FORKS_PATH: &str = "/agdx/forks";
36/// `GET /agdx/graphs` to list graph projections, `POST` to register.
37pub const GRAPHS_PATH: &str = "/agdx/graphs";
38/// `GET /agdx/clients` to list live connections with their advertised metadata,
39/// filtered and paginated by query parameters. The HTTP face of the
40/// `AGDX_GET_CLIENTS_METADATA` discovery read.
41pub const CLIENTS_PATH: &str = "/agdx/clients";
42/// `GET /agdx/runs` to list runs (filtered and paged, [`RunsQuery`]), `POST`
43/// to submit one (a JSON `AgentSubmit` body). The HTTP face of the
44/// `AGDX_AGENT_*` run-registry band.
45pub const RUNS_PATH: &str = "/agdx/runs";
46/// `GET /agdx/authz/whoami`: the caller's effective governance roles and grants.
47pub const AUTHZ_WHOAMI_PATH: &str = "/agdx/authz/whoami";
48/// `GET /agdx/authz/roles`: list governance roles.
49pub const AUTHZ_ROLES_PATH: &str = "/agdx/authz/roles";
50
51pub fn destination_path(id: DestinationId) -> String {
52    format!("{DESTINATIONS_PATH}/{id}")
53}
54
55pub fn destination_enable_path(id: DestinationId) -> String {
56    format!("{}/{id}/enable", DESTINATIONS_PATH)
57}
58
59pub fn destination_disable_path(id: DestinationId) -> String {
60    format!("{}/{id}/disable", DESTINATIONS_PATH)
61}
62
63pub fn destination_status_path(id: DestinationId) -> String {
64    format!("{}/{id}/status", DESTINATIONS_PATH)
65}
66
67pub fn destination_checkpoint_path(id: DestinationId) -> String {
68    format!("{}/{id}/checkpoint", DESTINATIONS_PATH)
69}
70
71pub fn destination_retention_gap_path(id: DestinationId) -> String {
72    format!("{}/{id}/retention-gap", DESTINATIONS_PATH)
73}
74
75pub fn destination_prepared_attempt_path(id: DestinationId) -> String {
76    format!("{}/{id}/prepared-attempt", DESTINATIONS_PATH)
77}
78
79pub fn destination_table_path(id: DestinationId) -> String {
80    format!("{}/{id}/table", DESTINATIONS_PATH)
81}
82
83pub fn destination_table_schema_path(id: DestinationId) -> String {
84    format!("{}/{id}/table/schema", DESTINATIONS_PATH)
85}
86
87pub fn destination_snapshots_path(id: DestinationId) -> String {
88    format!("{}/{id}/table/snapshots", DESTINATIONS_PATH)
89}
90
91pub fn destination_current_snapshot_path(id: DestinationId) -> String {
92    format!("{}/{id}/table/current-snapshot", DESTINATIONS_PATH)
93}
94
95pub fn destination_snapshot_path(id: DestinationId, snapshot_id: i64) -> String {
96    format!("{}/{id}/table/snapshots/{snapshot_id}", DESTINATIONS_PATH)
97}
98
99pub fn destination_files_path(id: DestinationId) -> String {
100    format!("{}/{id}/table/files", DESTINATIONS_PATH)
101}
102
103pub fn destination_metrics_path(id: DestinationId) -> String {
104    format!("{}/{id}/table/metrics", DESTINATIONS_PATH)
105}
106
107pub fn query_execution_path(id: QueryExecutionId) -> String {
108    format!("{QUERY_PATH}/{id}")
109}
110
111pub fn query_page_path(id: QueryExecutionId) -> String {
112    format!("{QUERY_PATH}/{id}/pages")
113}
114
115pub fn query_cancel_path(id: QueryExecutionId) -> String {
116    format!("{QUERY_PATH}/{id}/cancel")
117}
118
119pub fn destination_operation_path(id: DestinationOperationId) -> String {
120    format!("{DESTINATIONS_PATH}/operations/{id}")
121}
122
123#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
124pub struct DestinationListQuery {
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub source_stream: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub source_topic: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub name_contains: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub after: Option<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub limit: Option<usize>,
135}
136
137#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
138pub struct DestinationPageView {
139    pub destinations: Vec<DestinationView>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub next_cursor: Option<String>,
142    pub global_state_revision: u64,
143    pub consistency: crate::checkpoint::CheckpointReadConsistency,
144}
145
146#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147pub struct DestinationView {
148    pub destination: MaterializationDestination,
149    pub status: DestinationCheckpointStatus,
150}
151
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153pub struct DestinationMutationBody {
154    pub checkpoint: CheckpointRequestEnvelope,
155}
156
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158pub struct AcceptedOperationView {
159    pub operation_id: DestinationOperationId,
160    pub request_id: crate::checkpoint::CheckpointRequestId,
161    pub state: OperationState,
162    pub submitted_at_micros: u64,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub completed_at_micros: Option<u64>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub error: Option<OperationErrorView>,
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170pub struct OperationErrorView {
171    pub code: ResultCode,
172    pub message: String,
173}
174
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177#[non_exhaustive]
178pub enum OperationState {
179    Accepted,
180    Running,
181    Succeeded,
182    Failed,
183    Cancelled,
184}
185
186#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
187pub struct QueryRouteListQuery {
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub name_contains: Option<String>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub after: Option<String>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub limit: Option<usize>,
194}
195
196#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
197pub struct QueryRoutePageView {
198    pub routes: Vec<QueryRoute>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub next_cursor: Option<String>,
201    pub definition_revision: u64,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
205pub struct TableView {
206    pub table_uuid: UuidValue,
207    pub destination_id: DestinationId,
208    pub destination_generation: u64,
209    pub namespace: Vec<String>,
210    pub table: String,
211    pub current_snapshot_id: i64,
212    pub current_schema_id: i32,
213    pub current_partition_spec_id: i32,
214    pub metadata_identity: String,
215    pub properties: BTreeMap<String, String>,
216}
217
218#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
219pub struct TableSchemaView {
220    pub table_uuid: UuidValue,
221    pub iceberg_schema_id: i32,
222    pub logical_schema: LogicalSchema,
223}
224
225#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
226pub struct SnapshotListQuery {
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub before_snapshot_id: Option<i64>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub limit: Option<usize>,
231}
232
233#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
234pub struct SnapshotPageView {
235    pub snapshots: Vec<TableSnapshotView>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub next_before_snapshot_id: Option<i64>,
238}
239
240#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
241pub struct TableSnapshotView {
242    pub snapshot_id: i64,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub parent_snapshot_id: Option<i64>,
245    pub sequence_number: i64,
246    pub committed_at_micros: u64,
247    pub schema_id: i32,
248    pub partition_spec_id: i32,
249    pub materialization_boundary_digest: Digest32,
250    pub checkpoint_revision: u64,
251    pub summary: BTreeMap<String, String>,
252}
253
254#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
255pub struct TableFileListQuery {
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub snapshot_id: Option<i64>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub after: Option<String>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub limit: Option<usize>,
262}
263
264#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
265pub struct TableFilePageView {
266    pub files: Vec<TableFileView>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub next_cursor: Option<String>,
269}
270
271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
272pub struct TableFileView {
273    pub object_identity: String,
274    pub file_size_bytes: u64,
275    pub row_count: u64,
276    pub partition: BTreeMap<String, TypedValue>,
277    pub lower_bounds: BTreeMap<u32, TypedValue>,
278    pub upper_bounds: BTreeMap<u32, TypedValue>,
279    pub null_value_counts: BTreeMap<u32, u64>,
280}
281
282#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
283pub struct TableMetricsView {
284    pub snapshot_id: i64,
285    pub data_file_count: u64,
286    pub delete_file_count: u64,
287    pub total_rows: u64,
288    pub total_bytes: u64,
289    pub partition_count: u64,
290}
291
292#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
293pub struct QueryPageBody {
294    pub cursor: String,
295    pub deadline_micros: u64,
296}
297
298#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
299pub struct QueryExecutionView {
300    pub status: QueryExecutionStatus,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub result: Option<QueryResult>,
303}
304
305#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
306pub struct DestinationIssueView {
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub retention_gap: Option<RetentionGap>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub prepared_attempt: Option<PreparedAttemptSummary>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub block: Option<crate::checkpoint::DestinationBlock>,
313    pub checkpoint_revision: u64,
314    pub consistency: crate::checkpoint::CheckpointReadConsistency,
315}
316
317/// `GET`/`PUT`/`DELETE /agdx/authz/roles/{name}`.
318pub fn authz_role_path(name: &str) -> String {
319    format!("{AUTHZ_ROLES_PATH}/{name}")
320}
321
322/// `GET`/`PUT /agdx/authz/users/{id}/roles`: read or replace one user's role set.
323pub fn authz_user_roles_path(user_id: u32) -> String {
324    format!("/agdx/authz/users/{user_id}/roles")
325}
326
327/// `DELETE`/`GET /agdx/graphs/{id}`: drop or read a graph projection.
328pub fn graph_path(id: &str) -> String {
329    format!("{GRAPHS_PATH}/{id}")
330}
331
332/// `POST /agdx/graph/{name}/query`: run a traversal (a `GraphQuery` body).
333pub fn graph_query_path(name: &str) -> String {
334    format!("/agdx/graph/{name}/query")
335}
336
337/// `GET /agdx/graph/{name}/neighbors/{node}`: one-hop neighbor read.
338pub fn graph_neighbors_path(name: &str, node: &str) -> String {
339    format!("/agdx/graph/{name}/neighbors/{node}")
340}
341
342/// `GET`/`DELETE /agdx/projections/{id}`.
343pub fn projection_path(id: &str) -> String {
344    format!("{PROJECTIONS_PATH}/{id}")
345}
346
347/// `GET`/`DELETE /agdx/schemas/{id}`.
348pub fn schema_path(id: u32) -> String {
349    format!("{SCHEMAS_PATH}/{id}")
350}
351
352/// `POST /agdx/schemas/{id}/decode`.
353pub fn schema_decode_path(id: u32) -> String {
354    format!("{SCHEMAS_PATH}/{id}/decode")
355}
356
357/// `GET /agdx/kv/{namespace}` to scan, `DELETE` to bulk-delete.
358pub fn kv_namespace_path(namespace: &str) -> String {
359    format!("{KV_PATH}/{namespace}")
360}
361
362/// `GET`/`PUT`/`DELETE /agdx/kv/{namespace}/{key}`. `key` is the URL-safe
363/// unpadded base64 form of the key bytes, the encoding this surface uses for
364/// every binary body.
365pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
366    format!("{KV_PATH}/{namespace}/{key_b64}")
367}
368
369/// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write (compare-and-swap).
370/// The precondition rides the query string (`expect_version` or `expect_absent`)
371/// and the value rides the raw body, like the plain `PUT`. A success replies
372/// `CasCommittedView` with the new version, a precondition miss replies `409`
373/// with an `ErrorBody` of code `conflict` whose `detail` carries the current
374/// version.
375pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
376    format!("{KV_PATH}/{namespace}/{key_b64}/cas")
377}
378
379/// `DELETE /agdx/forks/{id}`.
380pub fn fork_path(id: &str) -> String {
381    format!("{FORKS_PATH}/{id}")
382}
383
384/// `POST /agdx/forks/{id}/promote`.
385pub fn fork_promote_path(id: &str) -> String {
386    format!("{FORKS_PATH}/{id}/promote")
387}
388
389/// `PUT /agdx/forks/{id}/rows`.
390pub fn fork_rows_path(id: &str) -> String {
391    format!("{FORKS_PATH}/{id}/rows")
392}
393
394/// `GET /agdx/runs/{id}`: read one run's status.
395pub fn run_path(id: &str) -> String {
396    format!("{RUNS_PATH}/{id}")
397}
398
399/// `POST /agdx/runs/{id}/cancel`: record the cancel intent on a run.
400pub fn run_cancel_path(id: &str) -> String {
401    format!("{RUNS_PATH}/{id}/cancel")
402}
403
404/// `GET /agdx/capabilities` reply: what the `/agdx/*` surface offers on this
405/// server, so a browser client can feature-detect before showing the
406/// projections / query / KV / fork views. Richer than the binary `AGDX_HELLO`
407/// probe (per-surface flags plus the wire op versions the JSON bodies must
408/// match), and it answers truthfully even when the managed backend is disabled (200
409/// with `managed: false`).
410#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
411#[non_exhaustive]
412pub struct Capabilities {
413    /// Connected to a managed plane at all (the root: with no plane every managed
414    /// surface below is off, and the reply still answers `200` with `managed:
415    /// false`).
416    pub managed: bool,
417    /// The managed query surface, its registry browse views, and the strongest
418    /// read-consistency it serves.
419    pub query: QueryCapsView,
420    /// Destination declarations, checkpoint status, and table inspection.
421    pub destinations: DestinationCapsView,
422    /// The managed key-value surface and its conditional-write support.
423    pub kv: KvCapsView,
424    /// Whether the knowledge-graph ops (traversal, neighbors) are served. The
425    /// agentic-memory API composes the query and graph surfaces, so it has no
426    /// flag of its own: a client reads `query` and `graph`.
427    #[serde(default)]
428    pub graph: bool,
429    /// Whether copy-on-write forks are served.
430    pub fork: bool,
431    /// Whether the agent and workflow control band is served. Off until the plane
432    /// serves it (the engine is a later phase).
433    #[serde(default)]
434    pub agent_workflow: bool,
435    /// Whether the change feed is published (one change record per committed
436    /// notifying projector batch on the changes topic). Off by default: a
437    /// client that waits on an unpublished feed would wait forever.
438    #[serde(default)]
439    pub watch: bool,
440    /// Whether the authorization control band (`AGDX_AUTHZ_*`) is served, so a
441    /// console can show its roles and bindings surface. Off by default.
442    #[serde(default)]
443    pub authz: bool,
444    pub versions: OpVersions,
445    /// Materialization backends the server currently exposes, so a client can
446    /// show what it may route to. Identity only (id + engine kind), no settings
447    /// or secrets. Empty (the default) is skipped on encode, so a pre-backends
448    /// capabilities reply stays byte-identical.
449    #[serde(default, skip_serializing_if = "Vec::is_empty")]
450    pub backends: Vec<BackendDescriptor>,
451    /// The stream/topic names this deployment uses, mirroring
452    /// [`crate::hello::BackendAnnounce::topology`]. Absent (the default) is
453    /// skipped on encode, so a pre-topology capabilities reply stays
454    /// byte-identical and an older reader falls back to
455    /// [`WireTopology::default`].
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub topology: Option<WireTopology>,
458}
459
460/// The managed query surface on the HTTP capabilities reply: whether it is
461/// served, its registry browse views, and the consistency it honors.
462#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
463pub struct QueryCapsView {
464    /// Whether `POST /agdx/query` is served.
465    pub available: bool,
466    /// Whether the projection registry browse routes are served.
467    pub projections: bool,
468    /// Whether the schema registry browse routes are served.
469    pub schemas: bool,
470    /// The strongest read-consistency the surface serves (the ladder
471    /// `eventual < read_your_writes < strong`, so a level implies the weaker
472    /// ones). Defaults to `eventual`, which every query surface serves.
473    #[serde(default)]
474    pub consistency: Consistency,
475    /// Whether lexical relevance search (`Query.text`) is served. Defaults
476    /// off, like every sub-feature: over-advertising would turn the clean
477    /// unsupported into a silent wrong answer.
478    #[serde(default)]
479    pub keyword: bool,
480    /// Whether opaque cursor page retrieval is served.
481    #[serde(default)]
482    pub cursor_paging: bool,
483    /// Whether an executing query can be cancelled by its execution identity.
484    #[serde(default)]
485    pub cancellation: bool,
486    /// Whether execution status can be polled independently of the first page.
487    #[serde(default)]
488    pub execution_status: bool,
489}
490
491#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
492pub struct DestinationCapsView {
493    pub available: bool,
494    #[serde(default)]
495    pub lifecycle: bool,
496    #[serde(default)]
497    pub checkpoint_status: bool,
498    #[serde(default)]
499    pub query_routes: bool,
500    #[serde(default)]
501    pub table_schema: bool,
502    #[serde(default)]
503    pub snapshots: bool,
504    #[serde(default)]
505    pub files: bool,
506    #[serde(default)]
507    pub metrics: bool,
508    #[serde(default)]
509    pub strongest_consistency: crate::checkpoint::CheckpointReadConsistency,
510}
511
512/// The managed key-value surface on the HTTP capabilities reply.
513#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
514pub struct KvCapsView {
515    /// Whether the get/set/scan routes are served.
516    pub available: bool,
517    /// Whether compare-and-swap (`AGDX_KV_CAS`) is served. Independent of plain
518    /// get/set: a backend that cannot do a conditional write leaves it off and a
519    /// `cas` returns a clean unsupported error.
520    #[serde(default)]
521    pub cas: bool,
522    /// Whether fenced compare-and-swap (`AGDX_KV_CAS_FENCED`) is served. Independent
523    /// of plain `cas`: a backend leaves it off when it cannot gate a write on a live
524    /// fence sequence.
525    #[serde(default)]
526    pub cas_fenced: bool,
527    /// Whether the full revocable fenced-lease contract is served: holder-scoped
528    /// acquire, renewal, release, live-lease fenced CAS, and barriered reads.
529    #[serde(default)]
530    pub fenced_leases: bool,
531}
532
533impl Capabilities {
534    /// Constructor for the non-exhaustive wire struct. The core surfaces track
535    /// `enabled`, the way the binary `AGDX_HELLO` probe answers. The per-surface
536    /// sub-features (`kv.cas`, `query.consistency` above `eventual`, `graph`) all
537    /// start off: a server must opt into each, never over-advertising (which would
538    /// turn a clean unsupported error into a silent wrong answer). Build them with
539    /// [`from_versions`](Self::from_versions) or the setters.
540    pub fn new(enabled: bool, versions: OpVersions) -> Self {
541        Self {
542            managed: enabled,
543            query: QueryCapsView {
544                available: enabled,
545                projections: enabled,
546                schemas: enabled,
547                consistency: Consistency::Eventual,
548                keyword: false,
549                cursor_paging: false,
550                cancellation: false,
551                execution_status: false,
552            },
553            destinations: DestinationCapsView {
554                available: false,
555                lifecycle: false,
556                checkpoint_status: false,
557                query_routes: false,
558                table_schema: false,
559                snapshots: false,
560                files: false,
561                metrics: false,
562                strongest_consistency:
563                    crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
564            },
565            kv: KvCapsView {
566                available: enabled,
567                cas: false,
568                cas_fenced: false,
569                fenced_leases: false,
570            },
571            graph: false,
572            fork: enabled,
573            agent_workflow: false,
574            watch: false,
575            authz: false,
576            versions,
577            backends: Vec::new(),
578            topology: None,
579        }
580    }
581
582    /// Advertise that the knowledge-graph ops are served. Off by default: a
583    /// server sets it only when a backend implements the graph surface.
584    #[must_use]
585    pub fn with_graph(mut self, value: bool) -> Self {
586        self.graph = value;
587        self
588    }
589
590    /// Advertise that the agent and workflow control band is served. Off by
591    /// default: a server sets it only when it serves the band.
592    #[must_use]
593    pub fn with_agent_workflow(mut self, value: bool) -> Self {
594        self.agent_workflow = value;
595        self
596    }
597
598    /// Set whether lexical keyword search is served on the query surface.
599    #[must_use]
600    pub fn with_query_keyword(mut self, value: bool) -> Self {
601        self.query.keyword = value;
602        self
603    }
604
605    #[must_use]
606    pub fn with_query_execution(mut self, paging: bool, cancellation: bool, status: bool) -> Self {
607        self.query.cursor_paging = paging;
608        self.query.cancellation = cancellation;
609        self.query.execution_status = status;
610        self
611    }
612
613    #[must_use]
614    pub fn with_destinations(mut self, destinations: DestinationCapsView) -> Self {
615        self.destinations = destinations;
616        self
617    }
618
619    /// Advertise that the change feed is published. Only a deployment whose
620    /// projector emits change records may set it.
621    #[must_use]
622    pub fn with_watch(mut self, value: bool) -> Self {
623        self.watch = value;
624        self
625    }
626
627    /// Advertise that the authorization control band is served.
628    #[must_use]
629    pub fn with_authz(mut self, value: bool) -> Self {
630        self.authz = value;
631        self
632    }
633
634    /// Advertise the materialization backends the server exposes. The wire pins
635    /// no engine, so a server lists whatever it has open by id and kind.
636    #[must_use]
637    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
638        self.backends = backends;
639        self
640    }
641
642    /// Advertise the deployment's `topology`, mirroring
643    /// [`crate::hello::BackendAnnounce::with_topology`].
644    #[must_use]
645    pub fn with_topology(mut self, topology: WireTopology) -> Self {
646        self.topology = Some(topology);
647        self
648    }
649
650    /// Advertise compare-and-swap on the KV surface (`AGDX_KV_CAS`). Only a
651    /// backend that does a genuine conditional write may set it.
652    #[must_use]
653    pub fn with_kv_cas(mut self, on: bool) -> Self {
654        self.kv.cas = on;
655        self
656    }
657
658    /// Advertise fenced compare-and-swap on the KV surface (`AGDX_KV_CAS_FENCED`).
659    /// Only a backend that gates a write on a live fence sequence may set it.
660    #[must_use]
661    pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
662        self.kv.cas_fenced = on;
663        self
664    }
665
666    /// Advertise the full revocable fenced-lease contract. This subsumes the
667    /// older fence-only `cas_fenced` capability.
668    #[must_use]
669    pub fn with_kv_fenced_leases(mut self, on: bool) -> Self {
670        self.kv.fenced_leases = on;
671        self.kv.cas_fenced |= on;
672        self
673    }
674
675    /// Advertise the strongest read-consistency the query surface serves.
676    #[must_use]
677    pub fn with_query_consistency(mut self, level: Consistency) -> Self {
678        self.query.consistency = level;
679        self
680    }
681
682    /// Build the HTTP capabilities from the same `OpVersions` the binary
683    /// `AGDX_HELLO` probe answers with, reading the per-surface sub-features
684    /// straight off its `features` bitset and `graph` op version. A server SHOULD
685    /// use this so its two capability carriages (the binary `features` bits and
686    /// these HTTP fields) cannot disagree: the one source drives both.
687    pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
688        use crate::hello::feature;
689        let consistency = if versions.has_feature(feature::STRONG_CONSISTENCY) {
690            Consistency::Strong
691        } else if versions.has_feature(feature::READ_YOUR_WRITES) {
692            Consistency::ReadYourWrites
693        } else {
694            Consistency::Eventual
695        };
696        let destinations_available =
697            enabled && versions.checkpoint > 0 && versions.has_feature(feature::DESTINATIONS);
698        Self::new(enabled, versions)
699            .with_kv_cas(versions.has_feature(feature::KV_CAS))
700            .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
701            .with_kv_fenced_leases(versions.has_feature(feature::KV_FENCED_LEASES))
702            .with_agent_workflow(versions.has_feature(feature::AGENT_WORKFLOW))
703            .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
704            .with_watch(versions.has_feature(feature::WATCH))
705            .with_authz(versions.has_feature(feature::AUTHZ))
706            .with_query_consistency(consistency)
707            .with_destinations(DestinationCapsView {
708                available: destinations_available,
709                lifecycle: destinations_available,
710                checkpoint_status: destinations_available,
711                query_routes: destinations_available,
712                table_schema: false,
713                snapshots: false,
714                files: false,
715                metrics: false,
716                strongest_consistency:
717                    crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
718            })
719            // The graph surface needs a backend that serves it, advertised as a
720            // non-zero graph op version, so it is gated on that rather than implied.
721            .with_graph(enabled && versions.graph > 0)
722    }
723}
724
725/// One KV entry on the HTTP surface. `key` and `value` are URL-safe unpadded
726/// base64, because keys and values are arbitrary bytes that JSON strings
727/// cannot carry raw.
728#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
729pub struct KvEntryView {
730    pub key: String,
731    pub value: String,
732    pub expires_at_micros: Option<u64>,
733    /// The memory scope, present only on a memory read-view row (kind, agent,
734    /// user, app, conversation, and a `SourceRef` source pointer to the origin
735    /// log record). Absent on a generic entry, so a UI can fold a recalled item
736    /// back to its source message. Skipped on the wire when absent.
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub scope: Option<crate::kv::MemoryRowScope>,
739    /// The origin log record this entry was folded from, mirroring
740    /// [`crate::kv::KvEntry::source`]: every managed write is log-first, so a
741    /// stored entry points back to the record that wrote it. Skipped when the
742    /// store did not stamp one.
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub source: Option<crate::graph::SourceRef>,
745}
746
747/// One KV scan page on the HTTP surface.
748#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
749pub struct KvPageView {
750    pub entries: Vec<KvEntryView>,
751    pub cursor: Option<String>,
752}
753
754/// One page of runs on the HTTP surface: the rows (the binary `AgentRunInfo`
755/// is already JSON-safe) plus the next-page cursor as URL-safe unpadded
756/// base64, like every binary value on this surface. Absent cursor means the
757/// last page.
758#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
759pub struct RunPageView {
760    pub runs: Vec<crate::agent_workflow::AgentRunInfo>,
761    pub cursor: Option<String>,
762}
763
764/// `DELETE /agdx/kv/{namespace}` reply: the number of entries removed.
765#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
766pub struct DeletedManyView {
767    pub deleted: usize,
768}
769
770/// One connection on the `GET /agdx/clients` discovery surface. `metadata` is
771/// URL-safe unpadded base64 of the opaque advertised bytes (a JSON string cannot
772/// carry raw bytes), or `None` when the connection advertised none. The console
773/// decodes and interprets it per producer kind (an agent card or an app blob).
774#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
775pub struct ClientMetadataView {
776    pub client_id: u32,
777    pub user_id: Option<u32>,
778    pub transport: u8,
779    pub address: String,
780    pub consumer_groups_count: u32,
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub metadata: Option<String>,
783}
784
785/// One page of the `GET /agdx/clients` discovery read. `next_cursor` is the
786/// `after` query parameter for the next page, or `None` on the last page.
787#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
788pub struct ClientMetadataListView {
789    pub clients: Vec<ClientMetadataView>,
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub next_cursor: Option<u32>,
792}
793
794/// `GET /agdx/clients` query parameters: the discovery filters and the page
795/// window, all optional. Shared by the server handler and the typed client so the
796/// query string cannot drift between them.
797#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
798pub struct ClientsQuery {
799    /// Only return connections that advertised metadata.
800    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
801    pub with_metadata_only: bool,
802    /// Only return connections authenticated as this principal.
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub user_id: Option<u32>,
805    /// Page cursor: only connections with `client_id` greater than this.
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub after: Option<u32>,
808    /// Max entries per page (clamped server-side to the page cap).
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub limit: Option<u32>,
811}
812
813/// `POST /agdx/forks/{id}/promote` reply: the number of rows applied.
814#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
815pub struct PromotedView {
816    pub rows: usize,
817}
818
819/// One graph node on the HTTP surface: id as a string, its labels, and its
820/// attributes rendered as strings (e.g. the entity `value`) for a browser or
821/// wasm client that has no access to the typed `Value`.
822#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
823pub struct GraphNodeView {
824    pub id: String,
825    #[serde(default, skip_serializing_if = "Vec::is_empty")]
826    pub labels: Vec<String>,
827    #[serde(default, skip_serializing_if = "Vec::is_empty")]
828    pub attrs: Vec<(String, String)>,
829    /// The source this node was first observed in, if known. See [`SourceRef`].
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub source: Option<SourceRef>,
832}
833
834/// One graph edge on the HTTP surface: endpoint ids as strings, the type, and the
835/// weight.
836#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
837pub struct GraphEdgeView {
838    pub id: String,
839    pub from: String,
840    pub to: String,
841    pub edge_type: String,
842    pub weight: f32,
843    /// Valid-time window (epoch micros) for a bitemporal edge, omitted when open.
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub valid_from: Option<u64>,
846    #[serde(default, skip_serializing_if = "Option::is_none")]
847    pub valid_to: Option<u64>,
848    /// The source that most recently asserted this relationship, if known. See
849    /// [`SourceRef`].
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub source: Option<SourceRef>,
852}
853
854/// `POST /agdx/graph/{name}/query` reply: the reachable nodes, traversed edges,
855/// and (for a `paths` return) the reconstructed paths as id sequences.
856#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
857pub struct GraphResultView {
858    #[serde(default, skip_serializing_if = "Vec::is_empty")]
859    pub nodes: Vec<GraphNodeView>,
860    #[serde(default, skip_serializing_if = "Vec::is_empty")]
861    pub edges: Vec<GraphEdgeView>,
862    #[serde(default, skip_serializing_if = "Vec::is_empty")]
863    pub paths: Vec<PathView>,
864}
865
866/// One path in a `GraphResultView`: parallel node and edge id sequences, ids as
867/// Crockford-base32 strings (the JSON view of [`crate::graph::Path`]).
868#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
869pub struct PathView {
870    pub nodes: Vec<String>,
871    pub edges: Vec<String>,
872}
873
874/// `POST /agdx/schemas` body: the register request without an id. The managed
875/// backend allocates it and the reply carries it back as
876/// `{"SchemaRegistered":id}`.
877#[derive(Clone, Debug, Serialize, Deserialize)]
878pub struct RegisterSchemaBody {
879    pub source: crate::control::SchemaSource,
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub name: Option<String>,
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub version: Option<u32>,
884}
885
886/// `POST /agdx/schemas/{id}/decode` body: the record payload as URL-safe
887/// unpadded base64.
888#[derive(Clone, Debug, Serialize, Deserialize)]
889pub struct DecodeRecordBody {
890    pub payload: String,
891}
892
893/// `POST /agdx/forks` body.
894#[derive(Clone, Debug, Serialize, Deserialize)]
895pub struct ForkCreateBody {
896    pub fork_id: String,
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    pub parent: Option<String>,
899    #[serde(default)]
900    pub kind: ForkKind,
901    #[serde(default, skip_serializing_if = "Vec::is_empty")]
902    pub tables: Vec<String>,
903}
904
905/// `PUT /agdx/forks/{id}/rows` body. `payload_b64` is URL-safe unpadded base64,
906/// like every binary body on this surface.
907#[derive(Clone, Debug, Serialize, Deserialize)]
908pub struct ForkPutBody {
909    pub table: String,
910    pub partition_id: u32,
911    pub offset: u64,
912    #[serde(default)]
913    pub projection_id: String,
914    #[serde(default)]
915    pub projection_version: u32,
916    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
917    pub fields: BTreeMap<String, String>,
918    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
919    pub metadata: BTreeMap<String, String>,
920    #[serde(default, skip_serializing_if = "Option::is_none")]
921    pub payload_b64: Option<String>,
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub embedding: Option<String>,
924    #[serde(default)]
925    pub tombstone: bool,
926}
927
928/// `DELETE /agdx/bindings` body: which binding to remove, by its source stream
929/// and topic. `projection_ref` absent removes the whole binding for that source.
930/// `projection_ref` present removes only that one projection from the binding,
931/// leaving the rest. Mirrors `ControlCommand::RemoveBinding`.
932#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
933pub struct RemoveBindingBody {
934    pub stream: String,
935    pub topic: String,
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub projection_ref: Option<String>,
938}
939
940/// The canonical error body every `/agdx/*` route returns on a non-2xx status.
941/// The HTTP binding's rule is "a 2xx carries the bare `Ok` payload, a failure
942/// carries this": the status line gives the coarse class (from
943/// [`ResultCode::http_status`]) and this body gives the machine-dispatchable
944/// [`ResultCode`] plus a human `message`, so a client matches on `code` instead
945/// of grepping the message text (which is for humans and may change). `detail`
946/// carries optional structured context (e.g. the conflicting version on a CAS
947/// miss) as free-form JSON.
948#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
949pub struct ErrorBody {
950    pub code: ResultCode,
951    pub message: String,
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub detail: Option<serde_json::Value>,
954}
955
956impl ErrorBody {
957    /// An error body from a classified code and a human message.
958    pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
959        Self {
960            code,
961            message: message.into(),
962            detail: None,
963        }
964    }
965
966    /// Attach structured context.
967    #[must_use]
968    pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
969        self.detail = Some(detail);
970        self
971    }
972
973    /// The HTTP status this body's `code` maps to, so a server sets the status
974    /// line and the body from one value.
975    pub fn http_status(&self) -> u16 {
976        self.code.http_status()
977    }
978}
979
980impl From<&QueryError> for ErrorBody {
981    fn from(error: &QueryError) -> Self {
982        Self::new(ResultCode::from(error), error.to_string())
983    }
984}
985
986impl From<&KvError> for ErrorBody {
987    fn from(error: &KvError) -> Self {
988        Self::new(ResultCode::from(error), error.to_string())
989    }
990}
991
992impl From<&ForkError> for ErrorBody {
993    fn from(error: &ForkError) -> Self {
994        Self::new(ResultCode::from(error), error.to_string())
995    }
996}
997
998impl From<&crate::agent_workflow::AgentError> for ErrorBody {
999    fn from(error: &crate::agent_workflow::AgentError) -> Self {
1000        Self::new(ResultCode::from(error), error.to_string())
1001    }
1002}
1003
1004/// `?topic=` on `GET /agdx/projections`: filter to bindings off this source topic.
1005pub const PARAM_TOPIC: &str = "topic";
1006/// `?name_contains=` on a projection or schema list: substring over the name.
1007pub const PARAM_NAME_CONTAINS: &str = "name_contains";
1008/// `?id_prefix=` on `GET /agdx/projections`: keep ids starting with the prefix.
1009pub const PARAM_ID_PREFIX: &str = "id_prefix";
1010/// `?search=` on `GET /agdx/projections`: one substring matched against the
1011/// projection name OR id. A console with a single filter box maps to it. A
1012/// server matches it as `name_contains(name) OR id contains search`. Composes
1013/// (AND) with the narrower `name_contains` / `id_prefix` when several are set.
1014pub const PARAM_SEARCH: &str = "search";
1015/// `?prefix=` on a KV scan: base64url key prefix.
1016pub const PARAM_PREFIX: &str = "prefix";
1017/// `?start=` on a KV scan: base64url inclusive lower bound.
1018pub const PARAM_START: &str = "start";
1019/// `?end=` on a KV scan: base64url exclusive upper bound.
1020pub const PARAM_END: &str = "end";
1021/// `?key_contains=`: base64url substring the key must contain.
1022pub const PARAM_KEY_CONTAINS: &str = "key_contains";
1023/// `?limit=`: page size.
1024pub const PARAM_LIMIT: &str = "limit";
1025/// `?cursor=`: opaque continuation token from the prior page.
1026pub const PARAM_CURSOR: &str = "cursor";
1027/// `?expires_at_micros=` on a KV `PUT`: absolute expiry, epoch microseconds.
1028pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
1029/// `?expect_version=` on a KV compare-and-swap: apply only if the key holds
1030/// this exact version.
1031pub const PARAM_EXPECT_VERSION: &str = "expect_version";
1032/// `?expect_absent=` on a KV compare-and-swap: apply only if the key is absent
1033/// (create-if-absent).
1034pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
1035
1036/// Response header on `GET /agdx/kv/{namespace}/{key}` carrying the entry's
1037/// absolute expiry (epoch microseconds) as a decimal string. The value itself
1038/// rides the raw response body, so this header carries the one piece of
1039/// out-of-band metadata a single-key read needs. Owned here so the name is a
1040/// wire constant rather than an unscoped string. Absent means no expiry.
1041pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
1042
1043/// `GET /agdx/projections` filters. Every field is optional, and an absent field is
1044/// omitted from the query string (no empty `topic=`). Field names are the
1045/// `PARAM_*` consts verbatim, so the client serializer and the server parser
1046/// share one spelling.
1047#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1048pub struct ProjectionListQuery {
1049    #[serde(default, skip_serializing_if = "Option::is_none")]
1050    pub topic: Option<String>,
1051    #[serde(default, skip_serializing_if = "Option::is_none")]
1052    pub name_contains: Option<String>,
1053    #[serde(default, skip_serializing_if = "Option::is_none")]
1054    pub id_prefix: Option<String>,
1055    /// One substring matched against the projection name OR id, for a console
1056    /// with a single filter box. Composes (AND) with `name_contains`/`id_prefix`.
1057    #[serde(default, skip_serializing_if = "Option::is_none")]
1058    pub search: Option<String>,
1059}
1060
1061/// `GET /agdx/schemas` filters. `name_contains` is the substring filter on a
1062/// schema's optional name, the same spelling as the projection list, so the two
1063/// list surfaces share one filter vocabulary.
1064#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1065pub struct SchemaListQuery {
1066    #[serde(default, skip_serializing_if = "Option::is_none")]
1067    pub name_contains: Option<String>,
1068}
1069
1070/// `GET /agdx/kv/{namespace}` scan filters. The byte-valued bounds
1071/// (`prefix`/`start`/`end`/`key_contains`) are base64url, like every binary
1072/// value on this surface.
1073#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1074pub struct KvScanQuery {
1075    #[serde(default, skip_serializing_if = "Option::is_none")]
1076    pub prefix: Option<String>,
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub start: Option<String>,
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub end: Option<String>,
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub key_contains: Option<String>,
1083    /// The conversation lens: keep only rows the given conversation wrote (the
1084    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
1085    /// this on each record, so a scan of a memory namespace narrows to one
1086    /// conversation. Plain text, not base64url, since a conversation id is text.
1087    #[serde(default, skip_serializing_if = "Option::is_none")]
1088    pub conversation: Option<String>,
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub limit: Option<usize>,
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub cursor: Option<String>,
1093}
1094
1095/// `PUT /agdx/kv/{namespace}/{key}` query: an optional absolute expiry.
1096#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1097pub struct KvPutQuery {
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub expires_at_micros: Option<u64>,
1100}
1101
1102/// `GET /agdx/graph/{name}/neighbors/{node}` query: the traversal direction
1103/// (`out`, `in`, or `both`, omitted for the default `out`), an optional edge-type
1104/// filter, the hop depth (omitted for the default one hop), and a result limit
1105/// (omitted for the backend ceiling). One struct shared by the typed client and
1106/// the server route, so the two cannot drift.
1107#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1108pub struct GraphNeighborsQuery {
1109    #[serde(default, skip_serializing_if = "Option::is_none")]
1110    pub dir: Option<String>,
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub edge_type: Option<String>,
1113    #[serde(default, skip_serializing_if = "Option::is_none")]
1114    pub depth: Option<u32>,
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub limit: Option<usize>,
1117    /// Valid-time "as of" read (epoch micros): only edges valid at this instant.
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub as_of: Option<u64>,
1120    /// Restrict to elements a given conversation asserted (the text form of its
1121    /// `gen_ai.conversation.id`). Omitted reads the whole graph. The conversation
1122    /// lens over the neighbors route.
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub conversation: Option<String>,
1125}
1126
1127/// `GET /agdx/runs` filters: the binary `AgentList` rendered as query
1128/// parameters. `state` is the snake-case
1129/// [`AgentRunState`](crate::agent_workflow::AgentRunState) word, `cursor` is
1130/// the base64url form of the opaque page cursor, like every binary value on
1131/// this surface. One struct shared by the typed client and the server route,
1132/// so the two cannot drift.
1133#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1134pub struct RunsQuery {
1135    #[serde(default, skip_serializing_if = "Option::is_none")]
1136    pub agent_id: Option<String>,
1137    #[serde(default, skip_serializing_if = "Option::is_none")]
1138    pub state: Option<String>,
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub limit: Option<u32>,
1141    #[serde(default, skip_serializing_if = "Option::is_none")]
1142    pub cursor: Option<String>,
1143}
1144
1145/// `PUT /agdx/kv/{namespace}/{key}/cas` query: the compare-and-swap precondition
1146/// plus an optional expiry. Exactly one of `expect_version` (match the held
1147/// version) or `expect_absent` (create-if-absent) is set, mirroring the binary
1148/// `CasExpect`. The value rides the raw request body.
1149#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1150pub struct KvCasQuery {
1151    #[serde(default, skip_serializing_if = "Option::is_none")]
1152    pub expect_version: Option<u64>,
1153    #[serde(default, skip_serializing_if = "Option::is_none")]
1154    pub expect_absent: Option<bool>,
1155    #[serde(default, skip_serializing_if = "Option::is_none")]
1156    pub expires_at_micros: Option<u64>,
1157}
1158
1159/// `PUT /agdx/kv/{namespace}/{key}/cas` reply on success: the new version the
1160/// committed write took.
1161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1162pub struct CasCommittedView {
1163    pub version: u64,
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169
1170    #[test]
1171    fn given_path_builders_when_rendered_then_should_match_the_router() {
1172        assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1173        assert_eq!(projection_path("order.v1"), "/agdx/projections/order.v1");
1174        assert_eq!(schema_path(7), "/agdx/schemas/7");
1175        assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1176        assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1177        assert_eq!(
1178            kv_entry_path("sessions", "dXNlcjo0Mg"),
1179            "/agdx/kv/sessions/dXNlcjo0Mg"
1180        );
1181        assert_eq!(
1182            kv_cas_path("sessions", "dXNlcjo0Mg"),
1183            "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1184        );
1185        assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1186        assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1187        assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1188    }
1189
1190    #[test]
1191    fn given_capabilities_when_constructed_then_extended_features_default_off() {
1192        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1193        assert!(
1194            caps.query.available && caps.kv.available && caps.fork,
1195            "core surfaces track enabled"
1196        );
1197        assert!(
1198            !caps.kv.cas
1199                && !caps.kv.cas_fenced
1200                && !caps.kv.fenced_leases
1201                && caps.query.consistency == Consistency::Eventual,
1202            "sub-features must be opt-in, never on by default"
1203        );
1204        let opted = caps
1205            .with_kv_cas(true)
1206            .with_query_consistency(Consistency::ReadYourWrites);
1207        assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1208    }
1209
1210    #[test]
1211    fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
1212        let versions =
1213            OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
1214        let caps = Capabilities::from_versions(true, versions);
1215        assert!(caps.kv.fenced_leases);
1216        assert!(
1217            caps.kv.cas_fenced,
1218            "the full fenced-lease contract subsumes fenced CAS"
1219        );
1220    }
1221
1222    #[test]
1223    fn given_destination_versions_when_building_capabilities_then_the_core_surface_is_available() {
1224        use crate::codes::CHECKPOINT_OP_VERSION;
1225        use crate::hello::feature;
1226
1227        let versions = OpVersions::new(2, 2, 1, 1)
1228            .with_checkpoint(CHECKPOINT_OP_VERSION)
1229            .with_features(feature::DESTINATIONS);
1230        let caps = Capabilities::from_versions(true, versions);
1231
1232        assert!(caps.destinations.available);
1233        assert!(caps.destinations.lifecycle);
1234        assert!(caps.destinations.checkpoint_status);
1235        assert!(caps.destinations.query_routes);
1236        assert!(!caps.destinations.table_schema);
1237    }
1238
1239    #[test]
1240    fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1241        use crate::destination::BackendResourceId;
1242        use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1243        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1244            BackendDescriptor::new(
1245                BackendResourceId::from_u128(1),
1246                BackendMode::Operational,
1247                "Embedded",
1248                BackendImplementation {
1249                    kind: "embedded".to_owned(),
1250                    version: "1.0.0".to_owned(),
1251                },
1252                1,
1253                1,
1254            ),
1255            BackendDescriptor::new(
1256                BackendResourceId::from_u128(2),
1257                BackendMode::Lakehouse,
1258                "Warehouse",
1259                BackendImplementation {
1260                    kind: "columnar".to_owned(),
1261                    version: "1.0.0".to_owned(),
1262                },
1263                1,
1264                1,
1265            ),
1266        ]);
1267        let json = serde_json::to_string(&caps).expect("serializes");
1268        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1269        assert_eq!(back.backends.len(), 2);
1270        assert_eq!(
1271            back.backends[1].resource_id,
1272            BackendResourceId::from_u128(2)
1273        );
1274        assert_eq!(back.backends[1].implementation.kind, "columnar");
1275
1276        // No advertised backends is omitted on the wire, so a pre-backends
1277        // capabilities reply stays byte-identical.
1278        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1279        let json = serde_json::to_string(&plain).expect("json");
1280        assert!(!json.contains("backends"), "empty backends omitted: {json}");
1281    }
1282
1283    #[test]
1284    fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1285        let custom = WireTopology {
1286            ops_stream: "custom-ops".to_owned(),
1287            ..WireTopology::default()
1288        };
1289        let caps =
1290            Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1291        let json = serde_json::to_string(&caps).expect("serializes");
1292        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1293        assert_eq!(back.topology, Some(custom));
1294
1295        // No advertised topology (the default) is omitted on the wire, so a
1296        // pre-topology capabilities reply stays byte-identical.
1297        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1298        let json = serde_json::to_string(&plain).expect("json");
1299        assert!(
1300            !json.contains("topology"),
1301            "absent topology omitted: {json}"
1302        );
1303    }
1304
1305    #[test]
1306    fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1307        let body = ErrorBody::from(&QueryError::IndexNotFound("orders".to_owned()));
1308        assert_eq!(body.code, ResultCode::NotFound);
1309        assert_eq!(body.http_status(), 404);
1310        assert!(body.message.contains("orders"));
1311        // Round-trips as JSON, the form the HTTP surface serves it in.
1312        let json = serde_json::to_string(&body).expect("serializes");
1313        let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1314        assert_eq!(back, body);
1315    }
1316
1317    #[test]
1318    #[cfg(feature = "http-client")]
1319    fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1320        let query = KvScanQuery {
1321            prefix: Some("dXNlcjo".to_owned()),
1322            limit: Some(50),
1323            ..Default::default()
1324        };
1325        let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1326        assert_eq!(encoded, "prefix=dXNlcjo&limit=50");
1327        // Field names are the PARAM_* consts verbatim.
1328        assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1329        assert!(encoded.contains(&format!("{PARAM_LIMIT}=")));
1330    }
1331
1332    #[test]
1333    #[cfg(feature = "http-client")]
1334    fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1335        let projections = ProjectionListQuery {
1336            name_contains: Some("order".to_owned()),
1337            id_prefix: Some("order.".to_owned()),
1338            ..Default::default()
1339        };
1340        let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1341        assert_eq!(encoded, "name_contains=order&id_prefix=order.");
1342        assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1343        assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1344
1345        let schemas = SchemaListQuery {
1346            name_contains: Some("Order".to_owned()),
1347        };
1348        assert_eq!(
1349            serde_urlencoded::to_string(&schemas).expect("encodes"),
1350            "name_contains=Order"
1351        );
1352    }
1353}