Skip to main content

laser_wire/
http.rs

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