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}
528
529impl Capabilities {
530    /// Constructor for the non-exhaustive wire struct. The core surfaces track
531    /// `enabled`, the way the binary `AGDX_HELLO` probe answers. The per-surface
532    /// sub-features (`kv.cas`, `query.consistency` above `eventual`, `graph`) all
533    /// start off: a server must opt into each, never over-advertising (which would
534    /// turn a clean unsupported error into a silent wrong answer). Build them with
535    /// [`from_versions`](Self::from_versions) or the setters.
536    pub fn new(enabled: bool, versions: OpVersions) -> Self {
537        Self {
538            managed: enabled,
539            query: QueryCapsView {
540                available: enabled,
541                projections: enabled,
542                schemas: enabled,
543                consistency: Consistency::Eventual,
544                keyword: false,
545                cursor_paging: false,
546                cancellation: false,
547                execution_status: false,
548            },
549            destinations: DestinationCapsView {
550                available: false,
551                lifecycle: false,
552                checkpoint_status: false,
553                query_routes: false,
554                table_schema: false,
555                snapshots: false,
556                files: false,
557                metrics: false,
558                strongest_consistency:
559                    crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
560            },
561            kv: KvCapsView {
562                available: enabled,
563                cas: false,
564                cas_fenced: false,
565            },
566            graph: false,
567            fork: enabled,
568            agent_workflow: false,
569            watch: false,
570            authz: false,
571            versions,
572            backends: Vec::new(),
573            topology: None,
574        }
575    }
576
577    /// Advertise that the knowledge-graph ops are served. Off by default: a
578    /// server sets it only when a backend implements the graph surface.
579    #[must_use]
580    pub fn with_graph(mut self, value: bool) -> Self {
581        self.graph = value;
582        self
583    }
584
585    /// Advertise that the agent and workflow control band is served. Off by
586    /// default: a server sets it only when it serves the band.
587    #[must_use]
588    pub fn with_agent_workflow(mut self, value: bool) -> Self {
589        self.agent_workflow = value;
590        self
591    }
592
593    /// Set whether lexical keyword search is served on the query surface.
594    #[must_use]
595    pub fn with_query_keyword(mut self, value: bool) -> Self {
596        self.query.keyword = value;
597        self
598    }
599
600    #[must_use]
601    pub fn with_query_execution(mut self, paging: bool, cancellation: bool, status: bool) -> Self {
602        self.query.cursor_paging = paging;
603        self.query.cancellation = cancellation;
604        self.query.execution_status = status;
605        self
606    }
607
608    #[must_use]
609    pub fn with_destinations(mut self, destinations: DestinationCapsView) -> Self {
610        self.destinations = destinations;
611        self
612    }
613
614    /// Advertise that the change feed is published. Only a deployment whose
615    /// projector emits change records may set it.
616    #[must_use]
617    pub fn with_watch(mut self, value: bool) -> Self {
618        self.watch = value;
619        self
620    }
621
622    /// Advertise that the authorization control band is served.
623    #[must_use]
624    pub fn with_authz(mut self, value: bool) -> Self {
625        self.authz = value;
626        self
627    }
628
629    /// Advertise the materialization backends the server exposes. The wire pins
630    /// no engine, so a server lists whatever it has open by id and kind.
631    #[must_use]
632    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
633        self.backends = backends;
634        self
635    }
636
637    /// Advertise the deployment's `topology`, mirroring
638    /// [`crate::hello::BackendAnnounce::with_topology`].
639    #[must_use]
640    pub fn with_topology(mut self, topology: WireTopology) -> Self {
641        self.topology = Some(topology);
642        self
643    }
644
645    /// Advertise compare-and-swap on the KV surface (`AGDX_KV_CAS`). Only a
646    /// backend that does a genuine conditional write may set it.
647    #[must_use]
648    pub fn with_kv_cas(mut self, on: bool) -> Self {
649        self.kv.cas = on;
650        self
651    }
652
653    /// Advertise fenced compare-and-swap on the KV surface (`AGDX_KV_CAS_FENCED`).
654    /// Only a backend that gates a write on a live fence sequence may set it.
655    #[must_use]
656    pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
657        self.kv.cas_fenced = on;
658        self
659    }
660
661    /// Advertise the strongest read-consistency the query surface serves.
662    #[must_use]
663    pub fn with_query_consistency(mut self, level: Consistency) -> Self {
664        self.query.consistency = level;
665        self
666    }
667
668    /// Build the HTTP capabilities from the same `OpVersions` the binary
669    /// `AGDX_HELLO` probe answers with, reading the per-surface sub-features
670    /// straight off its `features` bitset and `graph` op version. A server SHOULD
671    /// use this so its two capability carriages (the binary `features` bits and
672    /// these HTTP fields) cannot disagree: the one source drives both.
673    pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
674        use crate::hello::feature;
675        let consistency = if versions.has_feature(feature::STRONG_CONSISTENCY) {
676            Consistency::Strong
677        } else if versions.has_feature(feature::READ_YOUR_WRITES) {
678            Consistency::ReadYourWrites
679        } else {
680            Consistency::Eventual
681        };
682        let destinations_available =
683            enabled && versions.checkpoint > 0 && versions.has_feature(feature::DESTINATIONS);
684        Self::new(enabled, versions)
685            .with_kv_cas(versions.has_feature(feature::KV_CAS))
686            .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
687            .with_agent_workflow(versions.has_feature(feature::AGENT_WORKFLOW))
688            .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
689            .with_watch(versions.has_feature(feature::WATCH))
690            .with_authz(versions.has_feature(feature::AUTHZ))
691            .with_query_consistency(consistency)
692            .with_destinations(DestinationCapsView {
693                available: destinations_available,
694                lifecycle: destinations_available,
695                checkpoint_status: destinations_available,
696                query_routes: destinations_available,
697                table_schema: false,
698                snapshots: false,
699                files: false,
700                metrics: false,
701                strongest_consistency:
702                    crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
703            })
704            // The graph surface needs a backend that serves it, advertised as a
705            // non-zero graph op version, so it is gated on that rather than implied.
706            .with_graph(enabled && versions.graph > 0)
707    }
708}
709
710/// One KV entry on the HTTP surface. `key` and `value` are URL-safe unpadded
711/// base64, because keys and values are arbitrary bytes that JSON strings
712/// cannot carry raw.
713#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
714pub struct KvEntryView {
715    pub key: String,
716    pub value: String,
717    pub expires_at_micros: Option<u64>,
718    /// The memory scope, present only on a memory read-view row (kind, agent,
719    /// user, app, conversation, and a `SourceRef` source pointer to the origin
720    /// log record). Absent on a generic entry, so a UI can fold a recalled item
721    /// back to its source message. Skipped on the wire when absent.
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub scope: Option<crate::kv::MemoryRowScope>,
724    /// The origin log record this entry was folded from, mirroring
725    /// [`crate::kv::KvEntry::source`]: every managed write is log-first, so a
726    /// stored entry points back to the record that wrote it. Skipped when the
727    /// store did not stamp one.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub source: Option<crate::graph::SourceRef>,
730}
731
732/// One KV scan page on the HTTP surface.
733#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
734pub struct KvPageView {
735    pub entries: Vec<KvEntryView>,
736    pub cursor: Option<String>,
737}
738
739/// One page of runs on the HTTP surface: the rows (the binary `AgentRunInfo`
740/// is already JSON-safe) plus the next-page cursor as URL-safe unpadded
741/// base64, like every binary value on this surface. Absent cursor means the
742/// last page.
743#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
744pub struct RunPageView {
745    pub runs: Vec<crate::agent_workflow::AgentRunInfo>,
746    pub cursor: Option<String>,
747}
748
749/// `DELETE /agdx/kv/{namespace}` reply: the number of entries removed.
750#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
751pub struct DeletedManyView {
752    pub deleted: usize,
753}
754
755/// One connection on the `GET /agdx/clients` discovery surface. `metadata` is
756/// URL-safe unpadded base64 of the opaque advertised bytes (a JSON string cannot
757/// carry raw bytes), or `None` when the connection advertised none. The console
758/// decodes and interprets it per producer kind (an agent card or an app blob).
759#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
760pub struct ClientMetadataView {
761    pub client_id: u32,
762    pub user_id: Option<u32>,
763    pub transport: u8,
764    pub address: String,
765    pub consumer_groups_count: u32,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub metadata: Option<String>,
768}
769
770/// One page of the `GET /agdx/clients` discovery read. `next_cursor` is the
771/// `after` query parameter for the next page, or `None` on the last page.
772#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
773pub struct ClientMetadataListView {
774    pub clients: Vec<ClientMetadataView>,
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub next_cursor: Option<u32>,
777}
778
779/// `GET /agdx/clients` query parameters: the discovery filters and the page
780/// window, all optional. Shared by the server handler and the typed client so the
781/// query string cannot drift between them.
782#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
783pub struct ClientsQuery {
784    /// Only return connections that advertised metadata.
785    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
786    pub with_metadata_only: bool,
787    /// Only return connections authenticated as this principal.
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub user_id: Option<u32>,
790    /// Page cursor: only connections with `client_id` greater than this.
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub after: Option<u32>,
793    /// Max entries per page (clamped server-side to the page cap).
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub limit: Option<u32>,
796}
797
798/// `POST /agdx/forks/{id}/promote` reply: the number of rows applied.
799#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
800pub struct PromotedView {
801    pub rows: usize,
802}
803
804/// One graph node on the HTTP surface: id as a string, its labels, and its
805/// attributes rendered as strings (e.g. the entity `value`) for a browser or
806/// wasm client that has no access to the typed `Value`.
807#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
808pub struct GraphNodeView {
809    pub id: String,
810    #[serde(default, skip_serializing_if = "Vec::is_empty")]
811    pub labels: Vec<String>,
812    #[serde(default, skip_serializing_if = "Vec::is_empty")]
813    pub attrs: Vec<(String, String)>,
814    /// The source this node was first observed in, if known. See [`SourceRef`].
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub source: Option<SourceRef>,
817}
818
819/// One graph edge on the HTTP surface: endpoint ids as strings, the type, and the
820/// weight.
821#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
822pub struct GraphEdgeView {
823    pub id: String,
824    pub from: String,
825    pub to: String,
826    pub edge_type: String,
827    pub weight: f32,
828    /// Valid-time window (epoch micros) for a bitemporal edge, omitted when open.
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub valid_from: Option<u64>,
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub valid_to: Option<u64>,
833    /// The source that most recently asserted this relationship, if known. See
834    /// [`SourceRef`].
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub source: Option<SourceRef>,
837}
838
839/// `POST /agdx/graph/{name}/query` reply: the reachable nodes, traversed edges,
840/// and (for a `paths` return) the reconstructed paths as id sequences.
841#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
842pub struct GraphResultView {
843    #[serde(default, skip_serializing_if = "Vec::is_empty")]
844    pub nodes: Vec<GraphNodeView>,
845    #[serde(default, skip_serializing_if = "Vec::is_empty")]
846    pub edges: Vec<GraphEdgeView>,
847    #[serde(default, skip_serializing_if = "Vec::is_empty")]
848    pub paths: Vec<PathView>,
849}
850
851/// One path in a `GraphResultView`: parallel node and edge id sequences, ids as
852/// Crockford-base32 strings (the JSON view of [`crate::graph::Path`]).
853#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
854pub struct PathView {
855    pub nodes: Vec<String>,
856    pub edges: Vec<String>,
857}
858
859/// `POST /agdx/schemas` body: the register request without an id. The managed
860/// backend allocates it and the reply carries it back as
861/// `{"SchemaRegistered":id}`.
862#[derive(Clone, Debug, Serialize, Deserialize)]
863pub struct RegisterSchemaBody {
864    pub source: crate::control::SchemaSource,
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub name: Option<String>,
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub version: Option<u32>,
869}
870
871/// `POST /agdx/schemas/{id}/decode` body: the record payload as URL-safe
872/// unpadded base64.
873#[derive(Clone, Debug, Serialize, Deserialize)]
874pub struct DecodeRecordBody {
875    pub payload: String,
876}
877
878/// `POST /agdx/forks` body.
879#[derive(Clone, Debug, Serialize, Deserialize)]
880pub struct ForkCreateBody {
881    pub fork_id: String,
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub parent: Option<String>,
884    #[serde(default)]
885    pub kind: ForkKind,
886    #[serde(default, skip_serializing_if = "Vec::is_empty")]
887    pub tables: Vec<String>,
888}
889
890/// `PUT /agdx/forks/{id}/rows` body. `payload_b64` is URL-safe unpadded base64,
891/// like every binary body on this surface.
892#[derive(Clone, Debug, Serialize, Deserialize)]
893pub struct ForkPutBody {
894    pub table: String,
895    pub partition_id: u32,
896    pub offset: u64,
897    #[serde(default)]
898    pub projection_id: String,
899    #[serde(default)]
900    pub projection_version: u32,
901    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
902    pub fields: BTreeMap<String, String>,
903    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
904    pub metadata: BTreeMap<String, String>,
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    pub payload_b64: Option<String>,
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub embedding: Option<String>,
909    #[serde(default)]
910    pub tombstone: bool,
911}
912
913/// `DELETE /agdx/bindings` body: which binding to remove, by its source stream
914/// and topic. `projection_ref` absent removes the whole binding for that source.
915/// `projection_ref` present removes only that one projection from the binding,
916/// leaving the rest. Mirrors `ControlCommand::RemoveBinding`.
917#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
918pub struct RemoveBindingBody {
919    pub stream: String,
920    pub topic: String,
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub projection_ref: Option<String>,
923}
924
925/// The canonical error body every `/agdx/*` route returns on a non-2xx status.
926/// The HTTP binding's rule is "a 2xx carries the bare `Ok` payload, a failure
927/// carries this": the status line gives the coarse class (from
928/// [`ResultCode::http_status`]) and this body gives the machine-dispatchable
929/// [`ResultCode`] plus a human `message`, so a client matches on `code` instead
930/// of grepping the message text (which is for humans and may change). `detail`
931/// carries optional structured context (e.g. the conflicting version on a CAS
932/// miss) as free-form JSON.
933#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
934pub struct ErrorBody {
935    pub code: ResultCode,
936    pub message: String,
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub detail: Option<serde_json::Value>,
939}
940
941impl ErrorBody {
942    /// An error body from a classified code and a human message.
943    pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
944        Self {
945            code,
946            message: message.into(),
947            detail: None,
948        }
949    }
950
951    /// Attach structured context.
952    #[must_use]
953    pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
954        self.detail = Some(detail);
955        self
956    }
957
958    /// The HTTP status this body's `code` maps to, so a server sets the status
959    /// line and the body from one value.
960    pub fn http_status(&self) -> u16 {
961        self.code.http_status()
962    }
963}
964
965impl From<&QueryError> for ErrorBody {
966    fn from(error: &QueryError) -> Self {
967        Self::new(ResultCode::from(error), error.to_string())
968    }
969}
970
971impl From<&KvError> for ErrorBody {
972    fn from(error: &KvError) -> Self {
973        Self::new(ResultCode::from(error), error.to_string())
974    }
975}
976
977impl From<&ForkError> for ErrorBody {
978    fn from(error: &ForkError) -> Self {
979        Self::new(ResultCode::from(error), error.to_string())
980    }
981}
982
983impl From<&crate::agent_workflow::AgentError> for ErrorBody {
984    fn from(error: &crate::agent_workflow::AgentError) -> Self {
985        Self::new(ResultCode::from(error), error.to_string())
986    }
987}
988
989/// `?topic=` on `GET /agdx/projections`: filter to bindings off this source topic.
990pub const PARAM_TOPIC: &str = "topic";
991/// `?name_contains=` on a projection or schema list: substring over the name.
992pub const PARAM_NAME_CONTAINS: &str = "name_contains";
993/// `?id_prefix=` on `GET /agdx/projections`: keep ids starting with the prefix.
994pub const PARAM_ID_PREFIX: &str = "id_prefix";
995/// `?search=` on `GET /agdx/projections`: one substring matched against the
996/// projection name OR id. A console with a single filter box maps to it. A
997/// server matches it as `name_contains(name) OR id contains search`. Composes
998/// (AND) with the narrower `name_contains` / `id_prefix` when several are set.
999pub const PARAM_SEARCH: &str = "search";
1000/// `?prefix=` on a KV scan: base64url key prefix.
1001pub const PARAM_PREFIX: &str = "prefix";
1002/// `?start=` on a KV scan: base64url inclusive lower bound.
1003pub const PARAM_START: &str = "start";
1004/// `?end=` on a KV scan: base64url exclusive upper bound.
1005pub const PARAM_END: &str = "end";
1006/// `?key_contains=`: base64url substring the key must contain.
1007pub const PARAM_KEY_CONTAINS: &str = "key_contains";
1008/// `?limit=`: page size.
1009pub const PARAM_LIMIT: &str = "limit";
1010/// `?cursor=`: opaque continuation token from the prior page.
1011pub const PARAM_CURSOR: &str = "cursor";
1012/// `?expires_at_micros=` on a KV `PUT`: absolute expiry, epoch microseconds.
1013pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
1014/// `?expect_version=` on a KV compare-and-swap: apply only if the key holds
1015/// this exact version.
1016pub const PARAM_EXPECT_VERSION: &str = "expect_version";
1017/// `?expect_absent=` on a KV compare-and-swap: apply only if the key is absent
1018/// (create-if-absent).
1019pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
1020
1021/// Response header on `GET /agdx/kv/{namespace}/{key}` carrying the entry's
1022/// absolute expiry (epoch microseconds) as a decimal string. The value itself
1023/// rides the raw response body, so this header carries the one piece of
1024/// out-of-band metadata a single-key read needs. Owned here so the name is a
1025/// wire constant rather than an unscoped string. Absent means no expiry.
1026pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
1027
1028/// `GET /agdx/projections` filters. Every field is optional, and an absent field is
1029/// omitted from the query string (no empty `topic=`). Field names are the
1030/// `PARAM_*` consts verbatim, so the client serializer and the server parser
1031/// share one spelling.
1032#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1033pub struct ProjectionListQuery {
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub topic: Option<String>,
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub name_contains: Option<String>,
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub id_prefix: Option<String>,
1040    /// One substring matched against the projection name OR id, for a console
1041    /// with a single filter box. Composes (AND) with `name_contains`/`id_prefix`.
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub search: Option<String>,
1044}
1045
1046/// `GET /agdx/schemas` filters. `name_contains` is the substring filter on a
1047/// schema's optional name, the same spelling as the projection list, so the two
1048/// list surfaces share one filter vocabulary.
1049#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1050pub struct SchemaListQuery {
1051    #[serde(default, skip_serializing_if = "Option::is_none")]
1052    pub name_contains: Option<String>,
1053}
1054
1055/// `GET /agdx/kv/{namespace}` scan filters. The byte-valued bounds
1056/// (`prefix`/`start`/`end`/`key_contains`) are base64url, like every binary
1057/// value on this surface.
1058#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1059pub struct KvScanQuery {
1060    #[serde(default, skip_serializing_if = "Option::is_none")]
1061    pub prefix: Option<String>,
1062    #[serde(default, skip_serializing_if = "Option::is_none")]
1063    pub start: Option<String>,
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub end: Option<String>,
1066    #[serde(default, skip_serializing_if = "Option::is_none")]
1067    pub key_contains: Option<String>,
1068    /// The conversation lens: keep only rows the given conversation wrote (the
1069    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
1070    /// this on each record, so a scan of a memory namespace narrows to one
1071    /// conversation. Plain text, not base64url, since a conversation id is text.
1072    #[serde(default, skip_serializing_if = "Option::is_none")]
1073    pub conversation: Option<String>,
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub limit: Option<usize>,
1076    #[serde(default, skip_serializing_if = "Option::is_none")]
1077    pub cursor: Option<String>,
1078}
1079
1080/// `PUT /agdx/kv/{namespace}/{key}` query: an optional absolute expiry.
1081#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1082pub struct KvPutQuery {
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub expires_at_micros: Option<u64>,
1085}
1086
1087/// `GET /agdx/graph/{name}/neighbors/{node}` query: the traversal direction
1088/// (`out`, `in`, or `both`, omitted for the default `out`), an optional edge-type
1089/// filter, the hop depth (omitted for the default one hop), and a result limit
1090/// (omitted for the backend ceiling). One struct shared by the typed client and
1091/// the server route, so the two cannot drift.
1092#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1093pub struct GraphNeighborsQuery {
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub dir: Option<String>,
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub edge_type: Option<String>,
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub depth: Option<u32>,
1100    #[serde(default, skip_serializing_if = "Option::is_none")]
1101    pub limit: Option<usize>,
1102    /// Valid-time "as of" read (epoch micros): only edges valid at this instant.
1103    #[serde(default, skip_serializing_if = "Option::is_none")]
1104    pub as_of: Option<u64>,
1105    /// Restrict to elements a given conversation asserted (the text form of its
1106    /// `gen_ai.conversation.id`). Omitted reads the whole graph. The conversation
1107    /// lens over the neighbors route.
1108    #[serde(default, skip_serializing_if = "Option::is_none")]
1109    pub conversation: Option<String>,
1110}
1111
1112/// `GET /agdx/runs` filters: the binary `AgentList` rendered as query
1113/// parameters. `state` is the snake-case
1114/// [`AgentRunState`](crate::agent_workflow::AgentRunState) word, `cursor` is
1115/// the base64url form of the opaque page cursor, like every binary value on
1116/// this surface. One struct shared by the typed client and the server route,
1117/// so the two cannot drift.
1118#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1119pub struct RunsQuery {
1120    #[serde(default, skip_serializing_if = "Option::is_none")]
1121    pub agent_id: Option<String>,
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub state: Option<String>,
1124    #[serde(default, skip_serializing_if = "Option::is_none")]
1125    pub limit: Option<u32>,
1126    #[serde(default, skip_serializing_if = "Option::is_none")]
1127    pub cursor: Option<String>,
1128}
1129
1130/// `PUT /agdx/kv/{namespace}/{key}/cas` query: the compare-and-swap precondition
1131/// plus an optional expiry. Exactly one of `expect_version` (match the held
1132/// version) or `expect_absent` (create-if-absent) is set, mirroring the binary
1133/// `CasExpect`. The value rides the raw request body.
1134#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1135pub struct KvCasQuery {
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub expect_version: Option<u64>,
1138    #[serde(default, skip_serializing_if = "Option::is_none")]
1139    pub expect_absent: Option<bool>,
1140    #[serde(default, skip_serializing_if = "Option::is_none")]
1141    pub expires_at_micros: Option<u64>,
1142}
1143
1144/// `PUT /agdx/kv/{namespace}/{key}/cas` reply on success: the new version the
1145/// committed write took.
1146#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1147pub struct CasCommittedView {
1148    pub version: u64,
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154
1155    #[test]
1156    fn given_path_builders_when_rendered_then_should_match_the_router() {
1157        assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1158        assert_eq!(projection_path("order.v1"), "/agdx/projections/order.v1");
1159        assert_eq!(schema_path(7), "/agdx/schemas/7");
1160        assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1161        assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1162        assert_eq!(
1163            kv_entry_path("sessions", "dXNlcjo0Mg"),
1164            "/agdx/kv/sessions/dXNlcjo0Mg"
1165        );
1166        assert_eq!(
1167            kv_cas_path("sessions", "dXNlcjo0Mg"),
1168            "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1169        );
1170        assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1171        assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1172        assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1173    }
1174
1175    #[test]
1176    fn given_capabilities_when_constructed_then_extended_features_default_off() {
1177        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1178        assert!(
1179            caps.query.available && caps.kv.available && caps.fork,
1180            "core surfaces track enabled"
1181        );
1182        assert!(
1183            !caps.kv.cas && caps.query.consistency == Consistency::Eventual,
1184            "sub-features must be opt-in, never on by default"
1185        );
1186        let opted = caps
1187            .with_kv_cas(true)
1188            .with_query_consistency(Consistency::ReadYourWrites);
1189        assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1190    }
1191
1192    #[test]
1193    fn given_destination_versions_when_building_capabilities_then_the_core_surface_is_available() {
1194        use crate::codes::CHECKPOINT_OP_VERSION;
1195        use crate::hello::feature;
1196
1197        let versions = OpVersions::new(2, 2, 1, 1)
1198            .with_checkpoint(CHECKPOINT_OP_VERSION)
1199            .with_features(feature::DESTINATIONS);
1200        let caps = Capabilities::from_versions(true, versions);
1201
1202        assert!(caps.destinations.available);
1203        assert!(caps.destinations.lifecycle);
1204        assert!(caps.destinations.checkpoint_status);
1205        assert!(caps.destinations.query_routes);
1206        assert!(!caps.destinations.table_schema);
1207    }
1208
1209    #[test]
1210    fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1211        use crate::destination::BackendResourceId;
1212        use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1213        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1214            BackendDescriptor::new(
1215                BackendResourceId::from_u128(1),
1216                BackendMode::Operational,
1217                "Embedded",
1218                BackendImplementation {
1219                    kind: "embedded".to_owned(),
1220                    version: "1.0.0".to_owned(),
1221                },
1222                1,
1223                1,
1224            ),
1225            BackendDescriptor::new(
1226                BackendResourceId::from_u128(2),
1227                BackendMode::Lakehouse,
1228                "Warehouse",
1229                BackendImplementation {
1230                    kind: "columnar".to_owned(),
1231                    version: "1.0.0".to_owned(),
1232                },
1233                1,
1234                1,
1235            ),
1236        ]);
1237        let json = serde_json::to_string(&caps).expect("serializes");
1238        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1239        assert_eq!(back.backends.len(), 2);
1240        assert_eq!(
1241            back.backends[1].resource_id,
1242            BackendResourceId::from_u128(2)
1243        );
1244        assert_eq!(back.backends[1].implementation.kind, "columnar");
1245
1246        // No advertised backends is omitted on the wire, so a pre-backends
1247        // capabilities reply stays byte-identical.
1248        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1249        let json = serde_json::to_string(&plain).expect("json");
1250        assert!(!json.contains("backends"), "empty backends omitted: {json}");
1251    }
1252
1253    #[test]
1254    fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1255        let custom = WireTopology {
1256            ops_stream: "custom-ops".to_owned(),
1257            ..WireTopology::default()
1258        };
1259        let caps =
1260            Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1261        let json = serde_json::to_string(&caps).expect("serializes");
1262        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1263        assert_eq!(back.topology, Some(custom));
1264
1265        // No advertised topology (the default) is omitted on the wire, so a
1266        // pre-topology capabilities reply stays byte-identical.
1267        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1268        let json = serde_json::to_string(&plain).expect("json");
1269        assert!(
1270            !json.contains("topology"),
1271            "absent topology omitted: {json}"
1272        );
1273    }
1274
1275    #[test]
1276    fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1277        let body = ErrorBody::from(&QueryError::IndexNotFound("orders".to_owned()));
1278        assert_eq!(body.code, ResultCode::NotFound);
1279        assert_eq!(body.http_status(), 404);
1280        assert!(body.message.contains("orders"));
1281        // Round-trips as JSON, the form the HTTP surface serves it in.
1282        let json = serde_json::to_string(&body).expect("serializes");
1283        let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1284        assert_eq!(back, body);
1285    }
1286
1287    #[test]
1288    #[cfg(feature = "http-client")]
1289    fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1290        let query = KvScanQuery {
1291            prefix: Some("dXNlcjo".to_owned()),
1292            limit: Some(50),
1293            ..Default::default()
1294        };
1295        let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1296        assert_eq!(encoded, "prefix=dXNlcjo&limit=50");
1297        // Field names are the PARAM_* consts verbatim.
1298        assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1299        assert!(encoded.contains(&format!("{PARAM_LIMIT}=")));
1300    }
1301
1302    #[test]
1303    #[cfg(feature = "http-client")]
1304    fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1305        let projections = ProjectionListQuery {
1306            name_contains: Some("order".to_owned()),
1307            id_prefix: Some("order.".to_owned()),
1308            ..Default::default()
1309        };
1310        let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1311        assert_eq!(encoded, "name_contains=order&id_prefix=order.");
1312        assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1313        assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1314
1315        let schemas = SchemaListQuery {
1316            name_contains: Some("Order".to_owned()),
1317        };
1318        assert_eq!(
1319            serde_urlencoded::to_string(&schemas).expect("encodes"),
1320            "name_contains=Order"
1321        );
1322    }
1323}