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, FilterAnnounce, 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;
18use std::num::NonZeroU128;
19
20/// `GET /agdx/capabilities`: the feature-detection probe.
21pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
22/// `POST /agdx/query` (and `GET` with the query as a parameter).
23pub const QUERY_PATH: &str = "/agdx/query";
24/// `GET` and `POST /agdx/destinations`.
25pub const DESTINATIONS_PATH: &str = "/agdx/destinations";
26/// `GET /agdx/query-routes`.
27pub const QUERY_ROUTES_PATH: &str = "/agdx/query-routes";
28/// `GET /agdx/projections` to list, `POST` to register.
29pub const PROJECTIONS_PATH: &str = "/agdx/projections";
30/// `POST /agdx/bindings` to apply, `DELETE` to remove.
31pub const BINDINGS_PATH: &str = "/agdx/bindings";
32/// `GET /agdx/schemas` to list, `POST` to register.
33pub const SCHEMAS_PATH: &str = "/agdx/schemas";
34/// `GET /agdx/kv` to list the caller's namespaces.
35pub const KV_PATH: &str = "/agdx/kv";
36/// `GET /agdx/forks` to list, `POST` to create.
37pub const FORKS_PATH: &str = "/agdx/forks";
38/// `GET /agdx/graphs` to list graph projections, `POST` to register.
39pub const GRAPHS_PATH: &str = "/agdx/graphs";
40/// Prefix of the graph traversal and neighbor routes, `/agdx/graph/{name}/...`.
41pub const GRAPH_PATH: &str = "/agdx/graph";
42/// `GET /agdx/clients` to list live connections with their advertised metadata,
43/// filtered and paginated by query parameters. The HTTP face of the
44/// `AGDX_GET_CLIENTS_METADATA` discovery read.
45pub const CLIENTS_PATH: &str = "/agdx/clients";
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/// `GET /agdx/filters`: list saved consumer filters.
51pub const FILTERS_PATH: &str = "/agdx/filters";
52pub const FILTER_METRICS_PATH: &str = "/agdx/filters/metrics";
53/// `POST /agdx/filters/mutations`: one catalog mutation with its outcome.
54pub const FILTER_MUTATIONS_PATH: &str = "/agdx/filters/mutations";
55/// `POST /agdx/filters/validate`: validate and compile a filter.
56pub const FILTER_VALIDATE_PATH: &str = "/agdx/filters/validate";
57/// `POST /agdx/filters/test`: evaluate a filter against a supplied sample.
58pub const FILTER_TEST_PATH: &str = "/agdx/filters/test";
59/// `POST /agdx/filters/preview`: preview a filter over stored records.
60pub const FILTER_PREVIEW_PATH: &str = "/agdx/filters/preview";
61/// `GET /agdx/filter-bindings`: list consumer-group filter bindings.
62pub const FILTER_BINDINGS_PATH: &str = "/agdx/filter-bindings";
63/// `GET /agdx/group-policies/{stream}/{topic}/{group}`: confirmed group policy.
64pub const GROUP_POLICIES_PATH: &str = "/agdx/group-policies";
65/// Base path for reads of sessions in one named stream.
66pub const SESSIONS_PATH: &str = "/agdx/sessions";
67
68pub fn sessions_path(stream: &str) -> String {
69    format!("{SESSIONS_PATH}/{}", path_segment(stream))
70}
71
72pub fn session_changes_path(stream: &str) -> String {
73    format!("{}/changes", sessions_path(stream))
74}
75
76pub fn session_path(stream: &str, id: crate::agent::ConversationId) -> String {
77    format!(
78        "{}/{}",
79        sessions_path(stream),
80        path_segment(&id.to_string())
81    )
82}
83
84pub fn session_events_path(stream: &str, id: crate::agent::ConversationId) -> String {
85    format!("{}/events", session_path(stream, id))
86}
87
88pub fn session_state_path(stream: &str, id: crate::agent::ConversationId) -> String {
89    format!("{}/state", session_path(stream, id))
90}
91
92pub fn session_links_path(stream: &str, id: crate::agent::ConversationId) -> String {
93    format!("{}/links", session_path(stream, id))
94}
95
96pub fn session_sources_path(stream: &str, id: crate::agent::ConversationId) -> String {
97    format!("{}/sources", session_path(stream, id))
98}
99
100/// `GET /agdx/filters/{id}`.
101pub fn filter_path(id: u32) -> String {
102    format!("{FILTERS_PATH}/{id}")
103}
104
105/// `GET /agdx/filters/{id}/revisions`.
106pub fn filter_revisions_path(id: u32) -> String {
107    format!("{FILTERS_PATH}/{id}/revisions")
108}
109
110/// `GET /agdx/filters/operations/{operation_id}`.
111pub fn filter_operation_path(operation_id: u128) -> String {
112    format!("{FILTERS_PATH}/operations/{operation_id}")
113}
114
115/// `GET /agdx/filter-bindings/{stream}/{topic}/{group}`. Each name is
116/// percent-encoded as one path segment, because Iggy names can hold `/`, `?`,
117/// or `#`.
118pub fn filter_binding_path(stream: &str, topic: &str, group: &str) -> String {
119    format!(
120        "{FILTER_BINDINGS_PATH}/{}/{}/{}",
121        path_segment(stream),
122        path_segment(topic),
123        path_segment(group)
124    )
125}
126
127pub fn group_policy_path(stream: &str, topic: &str, group: &str) -> String {
128    format!(
129        "{GROUP_POLICIES_PATH}/{}/{}/{}",
130        path_segment(stream),
131        path_segment(topic),
132        path_segment(group)
133    )
134}
135
136/// Percent-encode `text` as one URL path segment: every byte outside the
137/// RFC 3986 unreserved set becomes `%XX`.
138pub fn path_segment(text: &str) -> String {
139    let mut out = String::with_capacity(text.len());
140    for byte in text.bytes() {
141        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
142            out.push(char::from(byte));
143        } else {
144            out.push_str(&format!("%{byte:02X}"));
145        }
146    }
147    out
148}
149
150pub fn destination_path(id: DestinationId) -> String {
151    format!("{DESTINATIONS_PATH}/{id}")
152}
153
154pub fn destination_enable_path(id: DestinationId) -> String {
155    format!("{}/{id}/enable", DESTINATIONS_PATH)
156}
157
158pub fn destination_disable_path(id: DestinationId) -> String {
159    format!("{}/{id}/disable", DESTINATIONS_PATH)
160}
161
162pub fn destination_status_path(id: DestinationId) -> String {
163    format!("{}/{id}/status", DESTINATIONS_PATH)
164}
165
166pub fn destination_checkpoint_path(id: DestinationId) -> String {
167    format!("{}/{id}/checkpoint", DESTINATIONS_PATH)
168}
169
170pub fn destination_retention_gap_path(id: DestinationId) -> String {
171    format!("{}/{id}/retention-gap", DESTINATIONS_PATH)
172}
173
174pub fn destination_prepared_attempt_path(id: DestinationId) -> String {
175    format!("{}/{id}/prepared-attempt", DESTINATIONS_PATH)
176}
177
178pub fn destination_table_path(id: DestinationId) -> String {
179    format!("{}/{id}/table", DESTINATIONS_PATH)
180}
181
182pub fn destination_table_schema_path(id: DestinationId) -> String {
183    format!("{}/{id}/table/schema", DESTINATIONS_PATH)
184}
185
186pub fn destination_snapshots_path(id: DestinationId) -> String {
187    format!("{}/{id}/table/snapshots", DESTINATIONS_PATH)
188}
189
190pub fn destination_current_snapshot_path(id: DestinationId) -> String {
191    format!("{}/{id}/table/current-snapshot", DESTINATIONS_PATH)
192}
193
194pub fn destination_snapshot_path(id: DestinationId, snapshot_id: i64) -> String {
195    format!("{}/{id}/table/snapshots/{snapshot_id}", DESTINATIONS_PATH)
196}
197
198pub fn destination_files_path(id: DestinationId) -> String {
199    format!("{}/{id}/table/files", DESTINATIONS_PATH)
200}
201
202pub fn destination_metrics_path(id: DestinationId) -> String {
203    format!("{}/{id}/table/metrics", DESTINATIONS_PATH)
204}
205
206pub fn query_execution_path(id: QueryExecutionId) -> String {
207    format!("{QUERY_PATH}/{id}")
208}
209
210pub fn query_page_path(id: QueryExecutionId) -> String {
211    format!("{QUERY_PATH}/{id}/pages")
212}
213
214pub fn query_cancel_path(id: QueryExecutionId) -> String {
215    format!("{QUERY_PATH}/{id}/cancel")
216}
217
218pub fn destination_operation_path(id: DestinationOperationId) -> String {
219    format!("{DESTINATIONS_PATH}/operations/{id}")
220}
221
222#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
223pub struct CheckpointReadQuery {
224    pub consistency: CheckpointReadConsistency,
225}
226
227impl CheckpointReadQuery {
228    pub const fn new(consistency: CheckpointReadConsistency) -> Self {
229        Self { consistency }
230    }
231}
232
233#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
234pub struct DestinationListQuery {
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub source_stream: Option<String>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub source_topic: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub name_contains: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub after: Option<String>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub limit: Option<usize>,
245    pub consistency: CheckpointReadConsistency,
246}
247
248#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
249pub struct DestinationPageView {
250    pub destinations: Vec<DestinationView>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub next_cursor: Option<String>,
253    pub global_state_revision: u64,
254    pub consistency: crate::checkpoint::CheckpointReadConsistency,
255}
256
257#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
258pub struct DestinationView {
259    pub destination: MaterializationDestination,
260    pub status: DestinationCheckpointStatus,
261}
262
263#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
264pub struct DestinationMutationBody {
265    pub checkpoint: CheckpointRequestEnvelope,
266}
267
268#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
269pub struct AcceptedOperationView {
270    pub operation_id: DestinationOperationId,
271    pub request_id: crate::checkpoint::CheckpointRequestId,
272    pub state: OperationState,
273    pub submitted_at_micros: u64,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub completed_at_micros: Option<u64>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub error: Option<OperationErrorView>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub result: Option<CheckpointMutationResult>,
280}
281
282#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
283pub struct OperationErrorView {
284    pub code: ResultCode,
285    pub message: String,
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290#[non_exhaustive]
291pub enum OperationState {
292    Accepted,
293    Running,
294    Succeeded,
295    Failed,
296    Cancelled,
297}
298
299#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
300pub struct QueryRouteListQuery {
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub name_contains: Option<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub after: Option<String>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub limit: Option<usize>,
307    pub consistency: CheckpointReadConsistency,
308}
309
310#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
311pub struct QueryRoutePageView {
312    pub routes: Vec<QueryRoute>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub next_cursor: Option<String>,
315    pub definition_revision: u64,
316    pub global_state_revision: u64,
317    pub consistency: CheckpointReadConsistency,
318}
319
320#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
321pub struct TableView {
322    pub table_uuid: UuidValue,
323    pub destination_id: DestinationId,
324    pub destination_generation: u64,
325    pub namespace: Vec<String>,
326    pub table: String,
327    pub current_snapshot_id: i64,
328    pub current_schema_id: i32,
329    pub current_partition_spec_id: i32,
330    pub metadata_identity: String,
331    pub properties: BTreeMap<String, String>,
332}
333
334#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
335pub struct TableSchemaView {
336    pub table_uuid: UuidValue,
337    pub iceberg_schema_id: i32,
338    pub logical_schema: LogicalSchema,
339}
340
341#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
342pub struct SnapshotListQuery {
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub before_snapshot_id: Option<i64>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub limit: Option<usize>,
347}
348
349#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
350pub struct SnapshotPageView {
351    pub snapshots: Vec<TableSnapshotView>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub next_before_snapshot_id: Option<i64>,
354}
355
356#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
357pub struct TableSnapshotView {
358    pub snapshot_id: i64,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub parent_snapshot_id: Option<i64>,
361    pub sequence_number: i64,
362    pub committed_at_micros: u64,
363    pub schema_id: i32,
364    pub partition_spec_id: i32,
365    pub materialization_boundary_digest: Digest32,
366    pub checkpoint_revision: u64,
367    pub summary: BTreeMap<String, String>,
368}
369
370#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
371pub struct TableFileListQuery {
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub snapshot_id: Option<i64>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub after: Option<String>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub limit: Option<usize>,
378}
379
380#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
381pub struct TableFilePageView {
382    pub files: Vec<TableFileView>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub next_cursor: Option<String>,
385}
386
387#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
388pub struct TableFileView {
389    pub object_identity: String,
390    pub file_size_bytes: u64,
391    pub row_count: u64,
392    pub partition: BTreeMap<String, TypedValue>,
393    pub lower_bounds: BTreeMap<u32, TypedValue>,
394    pub upper_bounds: BTreeMap<u32, TypedValue>,
395    pub null_value_counts: BTreeMap<u32, u64>,
396}
397
398#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
399pub struct TableMetricsView {
400    pub snapshot_id: i64,
401    pub data_file_count: u64,
402    pub delete_file_count: u64,
403    pub total_rows: u64,
404    pub total_bytes: u64,
405    pub partition_count: u64,
406}
407
408#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
409pub struct QueryPageBody {
410    pub cursor: String,
411    pub deadline_micros: u64,
412}
413
414#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
415pub struct QueryExecutionView {
416    pub status: QueryExecutionStatus,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub result: Option<QueryResult>,
419}
420
421#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
422pub struct DestinationIssueView {
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub retention_gap: Option<RetentionGap>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub prepared_attempt: Option<PreparedAttemptSummary>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub block: Option<crate::checkpoint::DestinationBlock>,
429    pub global_state_revision: u64,
430    pub checkpoint_revision: u64,
431    pub consistency: CheckpointReadConsistency,
432}
433
434/// One member of the closed destination HTTP operation set.
435#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437#[non_exhaustive]
438pub enum DestinationHttpOperation {
439    ListDestinations,
440    GetDestination,
441    CreateDestination,
442    EnableDestination,
443    DisableDestination,
444    GetOperation,
445    GetStatus,
446    GetCheckpoint,
447    GetRetentionGap,
448    GetPreparedAttempt,
449    ListQueryRoutes,
450    GetTable,
451    GetTableSchema,
452    GetCurrentSnapshot,
453    ListSnapshots,
454    GetSnapshot,
455    ListFiles,
456    GetMetrics,
457}
458
459/// One closed destination HTTP operation forwarded from Iggy to Plane.
460///
461/// The operation enum prevents arbitrary paths or methods from crossing the
462/// sidecar boundary. Path parameters stay textual, while query and body carry
463/// the JSON encodings of the operation's already-declared HTTP types.
464#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
465pub struct DestinationHttpRequest {
466    pub v: u32,
467    pub operation: DestinationHttpOperation,
468    pub parameters: Vec<String>,
469    pub query: Vec<u8>,
470    pub body: Vec<u8>,
471}
472
473/// Plane's HTTP-shaped response for one [`DestinationHttpRequest`].
474#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
475pub struct DestinationHttpReply {
476    pub status: u16,
477    pub body: Vec<u8>,
478}
479
480/// `GET`/`PUT`/`DELETE /agdx/authz/roles/{name}`.
481pub fn authz_role_path(name: &str) -> String {
482    format!("{AUTHZ_ROLES_PATH}/{name}")
483}
484
485/// `GET`/`PUT /agdx/authz/users/{id}/roles`: read or replace one user's role set.
486pub fn authz_user_roles_path(user_id: u32) -> String {
487    format!("/agdx/authz/users/{user_id}/roles")
488}
489
490/// `DELETE`/`GET /agdx/graphs/{id}`: drop or read a graph projection. The id
491/// is percent-encoded as one path segment, like every name below, because a
492/// stream-scoped name holds `/`.
493pub fn graph_path(id: &str) -> String {
494    format!("{GRAPHS_PATH}/{}", path_segment(id))
495}
496
497/// `POST /agdx/graph/{name}/query`: run a traversal (a `GraphQuery` body).
498pub fn graph_query_path(name: &str) -> String {
499    format!("{GRAPH_PATH}/{}/query", path_segment(name))
500}
501
502/// `GET /agdx/graph/{name}/neighbors/{node}`: one-hop neighbor read.
503pub fn graph_neighbors_path(name: &str, node: &str) -> String {
504    format!(
505        "{GRAPH_PATH}/{}/neighbors/{}",
506        path_segment(name),
507        path_segment(node)
508    )
509}
510
511/// `GET`/`DELETE /agdx/projections/{id}`.
512pub fn projection_path(id: &str) -> String {
513    format!("{PROJECTIONS_PATH}/{}", path_segment(id))
514}
515
516/// `GET`/`DELETE /agdx/schemas/{id}`.
517pub fn schema_path(id: u32) -> String {
518    format!("{SCHEMAS_PATH}/{id}")
519}
520
521/// `POST /agdx/schemas/{id}/decode`.
522pub fn schema_decode_path(id: u32) -> String {
523    format!("{SCHEMAS_PATH}/{id}/decode")
524}
525
526/// `GET /agdx/kv/{namespace}` to scan, `DELETE` to bulk-delete.
527pub fn kv_namespace_path(namespace: &str) -> String {
528    format!("{KV_PATH}/{}", path_segment(namespace))
529}
530
531/// `GET`/`PUT`/`DELETE /agdx/kv/{namespace}/{key}`. `key` is the URL-safe
532/// unpadded base64 form of the key bytes, the encoding this surface uses for
533/// every binary body.
534pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
535    format!("{}/{}", kv_namespace_path(namespace), path_segment(key_b64))
536}
537
538/// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write (compare-and-swap).
539/// The precondition rides the query string (`expect_version` or `expect_absent`)
540/// and the value rides the raw body, like the plain `PUT`. A success replies
541/// `CasCommittedView` with the new version, a precondition miss replies `409`
542/// with an `ErrorBody` of code `conflict` whose `detail` carries the current
543/// version.
544pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
545    format!("{}/cas", kv_entry_path(namespace, key_b64))
546}
547
548/// `DELETE /agdx/forks/{id}`.
549pub fn fork_path(id: &str) -> String {
550    format!("{FORKS_PATH}/{}", path_segment(id))
551}
552
553/// `POST /agdx/forks/{id}/promote`.
554pub fn fork_promote_path(id: &str) -> String {
555    format!("{}/promote", fork_path(id))
556}
557
558/// `PUT /agdx/forks/{id}/rows`.
559pub fn fork_rows_path(id: &str) -> String {
560    format!("{}/rows", fork_path(id))
561}
562
563/// `GET /agdx/capabilities` reply: what the `/agdx/*` surface offers on this
564/// server, so a browser client can feature-detect before showing the
565/// projections / query / KV / fork views. Richer than the binary `AGDX_HELLO`
566/// probe (per-surface flags plus the wire op versions the JSON bodies must
567/// match), and it answers truthfully even when the managed backend is disabled (200
568/// with `managed: false`).
569#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
570#[non_exhaustive]
571pub struct Capabilities {
572    /// Connected to a managed plane at all (the root: with no plane every managed
573    /// surface below is off, and the reply still answers `200` with `managed:
574    /// false`).
575    pub managed: bool,
576    /// The managed query surface, its registry browse views, and the strongest
577    /// read-consistency it serves.
578    pub query: QueryCapsView,
579    /// Destination declarations, checkpoint status, and table inspection.
580    pub destinations: DestinationCapsView,
581    /// The managed key-value surface and its conditional-write support.
582    pub kv: KvCapsView,
583    /// Whether the knowledge-graph ops (traversal, neighbors) are served. The
584    /// agentic-memory API composes the query and graph surfaces, so it has no
585    /// flag of its own: a client reads `query` and `graph`.
586    #[serde(default)]
587    pub graph: bool,
588    /// Whether copy-on-write forks are served.
589    pub fork: bool,
590    /// Whether managed session reads are served.
591    #[serde(default)]
592    pub sessions: bool,
593    /// Whether the change feed is published (one change record per committed
594    /// notifying projector batch on the changes topic). Off by default: a
595    /// client that waits on an unpublished feed would wait forever.
596    #[serde(default)]
597    pub watch: bool,
598    /// Whether the authorization control band (`AGDX_AUTHZ_*`) is served, so a
599    /// console can show its roles and bindings surface. Off by default.
600    #[serde(default)]
601    pub authz: bool,
602    /// Consumer filters: server-side evaluation and the saved-filter catalog,
603    /// reported separately.
604    #[serde(default)]
605    pub filters: FilterCapsView,
606    pub versions: OpVersions,
607    /// Materialization backends the server currently exposes, so a client can
608    /// show what it may route to. Identity only (id + engine kind), no settings
609    /// or secrets. Empty (the default) is skipped on encode, so a pre-backends
610    /// capabilities reply stays byte-identical.
611    #[serde(default, skip_serializing_if = "Vec::is_empty")]
612    pub backends: Vec<BackendDescriptor>,
613    /// The stream/topic names this deployment uses, mirroring
614    /// [`crate::hello::BackendAnnounce::topology`]. Absent (the default) is
615    /// skipped on encode, so a pre-topology capabilities reply stays
616    /// byte-identical and an older reader falls back to
617    /// [`WireTopology::default`].
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub topology: Option<WireTopology>,
620}
621
622/// The managed query surface on the HTTP capabilities reply: whether it is
623/// served, its registry browse views, and the consistency it honors.
624#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
625pub struct QueryCapsView {
626    /// Whether `POST /agdx/query` is served.
627    pub available: bool,
628    /// Whether the projection registry browse routes are served.
629    pub projections: bool,
630    /// Whether the schema registry browse routes are served.
631    pub schemas: bool,
632    /// The strongest read-consistency the surface serves (the ladder
633    /// `eventual < read_your_writes < strong`, so a level implies the weaker
634    /// ones). Defaults to `eventual`, which every query surface serves.
635    #[serde(default)]
636    pub consistency: Consistency,
637    /// Whether lexical relevance search (`Query.text`) is served. Defaults
638    /// off, like every sub-feature: over-advertising would turn the clean
639    /// unsupported into a silent wrong answer.
640    #[serde(default)]
641    pub keyword: bool,
642    /// Whether opaque cursor page retrieval is served.
643    #[serde(default)]
644    pub cursor_paging: bool,
645    /// Whether an executing query can be cancelled by its execution identity.
646    #[serde(default)]
647    pub cancellation: bool,
648    /// Whether execution status can be polled independently of the first page.
649    #[serde(default)]
650    pub execution_status: bool,
651}
652
653#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
654pub struct DestinationCapsView {
655    pub available: bool,
656    #[serde(default)]
657    pub lifecycle: bool,
658    #[serde(default)]
659    pub checkpoint_status: bool,
660    #[serde(default)]
661    pub query_routes: bool,
662    #[serde(default)]
663    pub table_schema: bool,
664    #[serde(default)]
665    pub snapshots: bool,
666    #[serde(default)]
667    pub files: bool,
668    #[serde(default)]
669    pub metrics: bool,
670    #[serde(default)]
671    pub strongest_consistency: crate::checkpoint::CheckpointReadConsistency,
672}
673
674/// Consumer filters on the HTTP capabilities reply. Native evaluation is served
675/// by the streaming server itself and holds with the managed backend off. The
676/// catalog (saved filters and group bindings) needs the backend.
677#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
678pub struct FilterCapsView {
679    /// Filtered reads, fenced acknowledgments, sample tests, and validation.
680    #[serde(default)]
681    pub native: bool,
682    /// Group-aware reads, including pass-through when no policy is bound.
683    #[serde(default)]
684    pub group_policy_reads: bool,
685    /// Bounded previews over stored records.
686    #[serde(default)]
687    pub preview: bool,
688    /// Filtered reads from the partition primary through an attached session.
689    #[serde(default)]
690    pub primary_routing: bool,
691    /// Saved filters, revisions, and mutation outcomes.
692    #[serde(default)]
693    pub catalog: bool,
694    /// Consumer groups bound to a saved revision.
695    #[serde(default)]
696    pub managed_groups: bool,
697    /// The evaluation rules served (`0` when native evaluation is off).
698    #[serde(default)]
699    pub evaluator_version: u32,
700    /// The payload codecs a filter may select.
701    #[serde(default, skip_serializing_if = "Vec::is_empty")]
702    pub codecs: Vec<crate::filter::FilterCodec>,
703}
704
705impl FilterCapsView {
706    /// The view a server serving native evaluation and a ready catalog advertises.
707    pub fn served(native: bool, catalog: bool) -> Self {
708        let evaluation = native.then(FilterAnnounce::served);
709        Self {
710            native,
711            group_policy_reads: false,
712            preview: native,
713            primary_routing: native,
714            catalog,
715            managed_groups: catalog,
716            evaluator_version: evaluation
717                .as_ref()
718                .map_or(0, |evaluation| evaluation.evaluator_version),
719            codecs: evaluation
720                .map(|evaluation| evaluation.codecs)
721                .unwrap_or_default(),
722        }
723    }
724
725    #[must_use]
726    pub const fn with_group_policy_reads(mut self, served: bool) -> Self {
727        self.group_policy_reads = served;
728        self
729    }
730
731    /// Returns a copy reporting the evaluation a server announced instead of
732    /// this build's own.
733    #[must_use]
734    pub fn with_evaluation(mut self, evaluation: FilterAnnounce) -> Self {
735        if self.native {
736            self.evaluator_version = evaluation.evaluator_version;
737            self.codecs = evaluation.codecs;
738        }
739        self
740    }
741}
742
743/// The managed key-value surface on the HTTP capabilities reply.
744#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
745pub struct KvCapsView {
746    /// Whether the get/set/scan routes are served.
747    pub available: bool,
748    /// Whether compare-and-swap (`AGDX_KV_CAS`) is served. Independent of plain
749    /// get/set: a backend that cannot do a conditional write leaves it off and a
750    /// `cas` returns a clean unsupported error.
751    #[serde(default)]
752    pub cas: bool,
753    /// Whether fenced compare-and-swap (`AGDX_KV_CAS_FENCED`) is served. Independent
754    /// of plain `cas`: a backend leaves it off when it cannot gate a write on a live
755    /// fence sequence.
756    #[serde(default)]
757    pub cas_fenced: bool,
758    /// Whether the full revocable fenced-lease contract is served: holder-scoped
759    /// acquire, renewal, release, live-lease fenced CAS, and barriered reads.
760    #[serde(default)]
761    pub fenced_leases: bool,
762}
763
764impl Capabilities {
765    /// Constructor for the non-exhaustive wire struct. The core surfaces track
766    /// `enabled`, the way the binary `AGDX_HELLO` probe answers. The per-surface
767    /// sub-features (`kv.cas`, `query.consistency` above `eventual`, `graph`) all
768    /// start off: a server must opt into each, never over-advertising (which would
769    /// turn a clean unsupported error into a silent wrong answer). Build them with
770    /// [`from_versions`](Self::from_versions) or the setters.
771    pub fn new(enabled: bool, versions: OpVersions) -> Self {
772        Self {
773            managed: enabled,
774            query: QueryCapsView {
775                available: enabled,
776                projections: enabled,
777                schemas: enabled,
778                consistency: Consistency::Eventual,
779                keyword: false,
780                cursor_paging: false,
781                cancellation: false,
782                execution_status: false,
783            },
784            destinations: DestinationCapsView {
785                available: false,
786                lifecycle: false,
787                checkpoint_status: false,
788                query_routes: false,
789                table_schema: false,
790                snapshots: false,
791                files: false,
792                metrics: false,
793                strongest_consistency:
794                    crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
795            },
796            kv: KvCapsView {
797                available: enabled,
798                cas: false,
799                cas_fenced: false,
800                fenced_leases: false,
801            },
802            graph: false,
803            fork: enabled,
804            sessions: false,
805            watch: false,
806            authz: false,
807            filters: FilterCapsView::default(),
808            versions,
809            backends: Vec::new(),
810            topology: None,
811        }
812    }
813
814    /// Advertise the consumer-filter surface.
815    #[must_use]
816    pub fn with_filters(mut self, filters: FilterCapsView) -> Self {
817        self.filters = filters;
818        self
819    }
820
821    /// Advertise that the knowledge-graph ops are served. Off by default: a
822    /// server sets it only when a backend implements the graph surface.
823    #[must_use]
824    pub fn with_graph(mut self, value: bool) -> Self {
825        self.graph = value;
826        self
827    }
828
829    /// Set whether lexical keyword search is served on the query surface.
830    #[must_use]
831    pub fn with_query_keyword(mut self, value: bool) -> Self {
832        self.query.keyword = value;
833        self
834    }
835
836    #[must_use]
837    pub fn with_query_execution(mut self, paging: bool, cancellation: bool, status: bool) -> Self {
838        self.query.cursor_paging = paging;
839        self.query.cancellation = cancellation;
840        self.query.execution_status = status;
841        self
842    }
843
844    #[must_use]
845    pub fn with_destinations(mut self, destinations: DestinationCapsView) -> Self {
846        self.destinations = destinations;
847        self
848    }
849
850    /// Advertise that managed session reads are served.
851    #[must_use]
852    pub fn with_sessions(mut self, value: bool) -> Self {
853        self.sessions = value;
854        self
855    }
856
857    /// Advertise that the change feed is published. Only a deployment whose
858    /// projector emits change records may set it.
859    #[must_use]
860    pub fn with_watch(mut self, value: bool) -> Self {
861        self.watch = value;
862        self
863    }
864
865    /// Advertise that the authorization control band is served.
866    #[must_use]
867    pub fn with_authz(mut self, value: bool) -> Self {
868        self.authz = value;
869        self
870    }
871
872    /// Advertise the materialization backends the server exposes. The wire pins
873    /// no engine, so a server lists whatever it has open by id and kind.
874    #[must_use]
875    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
876        self.backends = backends;
877        self
878    }
879
880    /// Advertise the deployment's `topology`, mirroring
881    /// [`crate::hello::BackendAnnounce::with_topology`].
882    #[must_use]
883    pub fn with_topology(mut self, topology: WireTopology) -> Self {
884        self.topology = Some(topology);
885        self
886    }
887
888    /// Advertise compare-and-swap on the KV surface (`AGDX_KV_CAS`). Only a
889    /// backend that does a genuine conditional write may set it.
890    #[must_use]
891    pub fn with_kv_cas(mut self, on: bool) -> Self {
892        self.kv.cas = on;
893        self
894    }
895
896    /// Advertise fenced compare-and-swap on the KV surface (`AGDX_KV_CAS_FENCED`).
897    /// Only a backend that gates a write on a live fence sequence may set it.
898    #[must_use]
899    pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
900        self.kv.cas_fenced = on;
901        self
902    }
903
904    /// Advertise the full revocable fenced-lease contract. This subsumes the
905    /// older fence-only `cas_fenced` capability.
906    #[must_use]
907    pub fn with_kv_fenced_leases(mut self, on: bool) -> Self {
908        self.kv.fenced_leases = on;
909        self.kv.cas_fenced |= on;
910        self
911    }
912
913    /// Advertise the strongest read-consistency the query surface serves.
914    #[must_use]
915    pub fn with_query_consistency(mut self, level: Consistency) -> Self {
916        self.query.consistency = level;
917        self
918    }
919
920    /// Build the HTTP capabilities from the same `OpVersions` the binary
921    /// `AGDX_HELLO` probe answers with, reading the per-surface sub-features
922    /// straight off its `features` bitset and `graph` op version. A server SHOULD
923    /// use this so its two capability carriages (the binary `features` bits and
924    /// these HTTP fields) cannot disagree: the one source drives both.
925    pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
926        use crate::hello::feature;
927        let consistency = if versions.has_feature(feature::STRONG_CONSISTENCY) {
928            Consistency::Strong
929        } else if versions.has_feature(feature::READ_YOUR_WRITES) {
930            Consistency::ReadYourWrites
931        } else {
932            Consistency::Eventual
933        };
934        let destinations_available =
935            enabled && versions.checkpoint > 0 && versions.has_feature(feature::DESTINATIONS);
936        Self::new(enabled, versions)
937            .with_kv_cas(versions.has_feature(feature::KV_CAS))
938            .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
939            .with_kv_fenced_leases(versions.has_feature(feature::KV_FENCED_LEASES))
940            .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
941            .with_watch(versions.has_feature(feature::WATCH))
942            .with_sessions(enabled && versions.has_feature(feature::SESSIONS))
943            .with_authz(versions.has_feature(feature::AUTHZ))
944            .with_query_consistency(consistency)
945            .with_destinations(DestinationCapsView {
946                available: destinations_available,
947                lifecycle: destinations_available,
948                checkpoint_status: destinations_available,
949                query_routes: destinations_available,
950                table_schema: false,
951                snapshots: false,
952                files: false,
953                metrics: false,
954                strongest_consistency:
955                    crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
956            })
957            // The graph surface needs a backend that serves it, advertised as a
958            // non-zero graph op version, so it is gated on that rather than implied.
959            .with_graph(enabled && versions.graph > 0)
960            .with_filters(
961                FilterCapsView::served(
962                    versions.has_feature(feature::CONSUMER_FILTERS),
963                    enabled && versions.filter > 0,
964                )
965                .with_group_policy_reads(versions.has_feature(feature::GROUP_POLICY_READS)),
966            )
967    }
968}
969
970/// One KV entry on the HTTP surface. `key` and `value` are URL-safe unpadded
971/// base64, because keys and values are arbitrary bytes that JSON strings
972/// cannot carry raw.
973#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
974pub struct KvEntryView {
975    pub key: String,
976    pub value: String,
977    pub expires_at_micros: Option<u64>,
978    /// The memory scope, present only on a memory read-view row (kind, agent,
979    /// user, app, conversation, and a `SourceRef` source pointer to the origin
980    /// log record). Absent on a generic entry, so a UI can fold a recalled item
981    /// back to its source message. Skipped on the wire when absent.
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub scope: Option<crate::kv::MemoryRowScope>,
984    /// The origin log record this entry was folded from, mirroring
985    /// [`crate::kv::KvEntry::source`]: every managed write is log-first, so a
986    /// stored entry points back to the record that wrote it. Skipped when the
987    /// store did not stamp one.
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub source: Option<crate::graph::SourceRef>,
990}
991
992/// One KV scan page on the HTTP surface.
993#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
994pub struct KvPageView {
995    pub entries: Vec<KvEntryView>,
996    pub cursor: Option<String>,
997}
998
999/// `DELETE /agdx/kv/{namespace}` reply: the number of entries removed.
1000#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1001pub struct DeletedManyView {
1002    pub deleted: usize,
1003}
1004
1005/// One connection on the `GET /agdx/clients` discovery surface. `metadata` is
1006/// URL-safe unpadded base64 of the opaque advertised bytes (a JSON string cannot
1007/// carry raw bytes), or `None` when the connection advertised none. The console
1008/// decodes and interprets it per producer kind (an agent card or an app blob).
1009#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1010pub struct ClientMetadataView {
1011    pub client_id: u32,
1012    pub user_id: Option<u32>,
1013    pub transport: u8,
1014    pub address: String,
1015    pub consumer_groups_count: u32,
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub metadata: Option<String>,
1018}
1019
1020/// One page of the `GET /agdx/clients` discovery read. `next_cursor` is the
1021/// `after` query parameter for the next page, or `None` on the last page.
1022#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1023pub struct ClientMetadataListView {
1024    pub clients: Vec<ClientMetadataView>,
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub next_cursor: Option<u32>,
1027}
1028
1029/// `GET /agdx/clients` query parameters: the discovery filters and the page
1030/// window, all optional. Shared by the server handler and the typed client so the
1031/// query string cannot drift between them.
1032#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1033pub struct ClientsQuery {
1034    /// Only return connections that advertised metadata.
1035    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
1036    pub with_metadata_only: bool,
1037    /// Only return connections authenticated as this principal.
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub user_id: Option<u32>,
1040    /// Page cursor: only connections with `client_id` greater than this.
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub after: Option<u32>,
1043    /// Max entries per page (clamped server-side to the page cap).
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub limit: Option<u32>,
1046}
1047
1048/// `POST /agdx/forks/{id}/promote` reply: the number of rows applied.
1049#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1050pub struct PromotedView {
1051    pub rows: usize,
1052}
1053
1054/// One graph node on the HTTP surface: id as a string, its labels, and its
1055/// attributes rendered as strings (e.g. the entity `value`) for a browser or
1056/// wasm client that has no access to the typed `Value`.
1057#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1058pub struct GraphNodeView {
1059    pub id: String,
1060    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1061    pub labels: Vec<String>,
1062    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1063    pub attrs: Vec<(String, String)>,
1064    /// The source this node was first observed in, if known. See [`SourceRef`].
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub source: Option<SourceRef>,
1067}
1068
1069/// One graph edge on the HTTP surface: endpoint ids as strings, the type, and the
1070/// weight.
1071#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1072pub struct GraphEdgeView {
1073    pub id: String,
1074    pub from: String,
1075    pub to: String,
1076    pub edge_type: String,
1077    pub weight: f32,
1078    /// Valid-time window (epoch micros) for a bitemporal edge, omitted when open.
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub valid_from: Option<u64>,
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub valid_to: Option<u64>,
1083    /// The source that most recently asserted this relationship, if known. See
1084    /// [`SourceRef`].
1085    #[serde(default, skip_serializing_if = "Option::is_none")]
1086    pub source: Option<SourceRef>,
1087}
1088
1089/// `POST /agdx/graph/{name}/query` reply: the reachable nodes, traversed edges,
1090/// and (for a `paths` return) the reconstructed paths as id sequences.
1091#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1092pub struct GraphResultView {
1093    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1094    pub nodes: Vec<GraphNodeView>,
1095    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1096    pub edges: Vec<GraphEdgeView>,
1097    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1098    pub paths: Vec<PathView>,
1099}
1100
1101/// One path in a `GraphResultView`: parallel node and edge id sequences, ids as
1102/// Crockford-base32 strings (the JSON view of [`crate::graph::Path`]).
1103#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1104pub struct PathView {
1105    pub nodes: Vec<String>,
1106    pub edges: Vec<String>,
1107}
1108
1109/// `POST /agdx/schemas` body: the register request without an id. The managed
1110/// backend allocates it and the reply carries it back as
1111/// `{"SchemaRegistered":id}`.
1112#[derive(Clone, Debug, Serialize, Deserialize)]
1113pub struct RegisterSchemaBody {
1114    pub source: crate::control::SchemaSource,
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub name: Option<String>,
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub version: Option<u32>,
1119    /// The stream whose registry allocates the id. Absent for the
1120    /// deployment-wide registry.
1121    #[serde(default, skip_serializing_if = "Option::is_none")]
1122    pub stream: Option<String>,
1123}
1124
1125/// `POST /agdx/schemas/{id}/decode` body: the record payload as URL-safe
1126/// unpadded base64.
1127#[derive(Clone, Debug, Serialize, Deserialize)]
1128pub struct DecodeRecordBody {
1129    pub payload: String,
1130}
1131
1132/// `POST /agdx/forks` body.
1133#[derive(Clone, Debug, Serialize, Deserialize)]
1134pub struct ForkCreateBody {
1135    pub fork_id: String,
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub parent: Option<String>,
1138    #[serde(default)]
1139    pub kind: ForkKind,
1140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1141    pub tables: Vec<String>,
1142}
1143
1144/// `PUT /agdx/forks/{id}/rows` body. `payload_b64` is URL-safe unpadded base64,
1145/// like every binary body on this surface.
1146#[derive(Clone, Debug, Serialize, Deserialize)]
1147pub struct ForkPutBody {
1148    pub table: String,
1149    pub partition_id: u32,
1150    pub offset: u64,
1151    #[serde(default)]
1152    pub projection_id: String,
1153    #[serde(default)]
1154    pub projection_version: u32,
1155    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1156    pub fields: BTreeMap<String, String>,
1157    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1158    pub metadata: BTreeMap<String, String>,
1159    #[serde(default, skip_serializing_if = "Option::is_none")]
1160    pub payload_b64: Option<String>,
1161    #[serde(default, skip_serializing_if = "Option::is_none")]
1162    pub embedding: Option<String>,
1163    #[serde(default)]
1164    pub tombstone: bool,
1165}
1166
1167/// `DELETE /agdx/bindings` body: which binding to remove, by its source stream
1168/// and topic. `projection_ref` absent removes the whole binding for that source.
1169/// `projection_ref` present removes only that one projection from the binding,
1170/// leaving the rest. Mirrors `ControlCommand::RemoveBinding`.
1171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1172pub struct RemoveBindingBody {
1173    pub stream: String,
1174    pub topic: String,
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub projection_ref: Option<String>,
1177}
1178
1179/// The canonical error body every `/agdx/*` route returns on a non-2xx status.
1180/// The HTTP binding's rule is "a 2xx carries the bare `Ok` payload, a failure
1181/// carries this": the status line gives the coarse class (from
1182/// [`ResultCode::http_status`]) and this body gives the machine-dispatchable
1183/// [`ResultCode`] plus a human `message`, so a client matches on `code` instead
1184/// of grepping the message text (which is for humans and may change). `detail`
1185/// carries optional structured context (e.g. the conflicting version on a CAS
1186/// miss) as free-form JSON.
1187#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1188pub struct ErrorBody {
1189    pub code: ResultCode,
1190    pub message: String,
1191    #[serde(default, skip_serializing_if = "Option::is_none")]
1192    pub detail: Option<serde_json::Value>,
1193}
1194
1195impl ErrorBody {
1196    /// An error body from a classified code and a human message.
1197    pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
1198        Self {
1199            code,
1200            message: message.into(),
1201            detail: None,
1202        }
1203    }
1204
1205    /// Attach structured context.
1206    #[must_use]
1207    pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
1208        self.detail = Some(detail);
1209        self
1210    }
1211
1212    /// The HTTP status this body's `code` maps to, so a server sets the status
1213    /// line and the body from one value.
1214    pub fn http_status(&self) -> u16 {
1215        self.code.http_status()
1216    }
1217}
1218
1219impl From<&QueryError> for ErrorBody {
1220    fn from(error: &QueryError) -> Self {
1221        Self::new(ResultCode::from(error), error.to_string())
1222    }
1223}
1224
1225impl From<&crate::filter::FilterError> for ErrorBody {
1226    fn from(error: &crate::filter::FilterError) -> Self {
1227        Self::new(error.code, error.message.clone())
1228            .with_detail(serde_json::json!({ "reason": error.reason }))
1229    }
1230}
1231
1232impl From<&KvError> for ErrorBody {
1233    fn from(error: &KvError) -> Self {
1234        Self::new(ResultCode::from(error), error.to_string())
1235    }
1236}
1237
1238impl From<&ForkError> for ErrorBody {
1239    fn from(error: &ForkError) -> Self {
1240        Self::new(ResultCode::from(error), error.to_string())
1241    }
1242}
1243
1244impl From<&crate::authz::AuthzError> for ErrorBody {
1245    fn from(error: &crate::authz::AuthzError) -> Self {
1246        Self::new(ResultCode::from(error), error.to_string())
1247    }
1248}
1249
1250impl From<&crate::session::SessionError> for ErrorBody {
1251    fn from(error: &crate::session::SessionError) -> Self {
1252        Self::new(ResultCode::from(error), error.to_string())
1253    }
1254}
1255
1256/// `?topic=` on `GET /agdx/projections`: filter to bindings off this source topic.
1257pub const PARAM_TOPIC: &str = "topic";
1258/// `?name_contains=` on a projection or schema list: substring over the name.
1259pub const PARAM_NAME_CONTAINS: &str = "name_contains";
1260/// `?id_prefix=` on `GET /agdx/projections`: keep ids starting with the prefix.
1261pub const PARAM_ID_PREFIX: &str = "id_prefix";
1262/// `?stream=` on the schema routes: the stream whose writer-schema registry
1263/// the request addresses. Absent addresses the deployment-wide registry.
1264pub const PARAM_STREAM: &str = "stream";
1265/// `?search=` on `GET /agdx/projections`: one substring matched against the
1266/// projection name OR id. A console with a single filter box maps to it. A
1267/// server matches it as `name_contains(name) OR id contains search`. Composes
1268/// (AND) with the narrower `name_contains` / `id_prefix` when several are set.
1269pub const PARAM_SEARCH: &str = "search";
1270/// `?prefix=` on a KV scan: base64url key prefix.
1271pub const PARAM_PREFIX: &str = "prefix";
1272/// `?start=` on a KV scan: base64url inclusive lower bound.
1273pub const PARAM_START: &str = "start";
1274/// `?end=` on a KV scan: base64url exclusive upper bound.
1275pub const PARAM_END: &str = "end";
1276/// `?key_contains=`: base64url substring the key must contain.
1277pub const PARAM_KEY_CONTAINS: &str = "key_contains";
1278/// `?limit=`: page size.
1279pub const PARAM_LIMIT: &str = "limit";
1280/// `?cursor=`: opaque continuation token from the prior page.
1281pub const PARAM_CURSOR: &str = "cursor";
1282/// `?status=` on a session list: one session status.
1283pub const PARAM_STATUS: &str = "status";
1284/// `?agent=` on a session list: sessions this agent took part in.
1285pub const PARAM_AGENT: &str = "agent";
1286/// `?text=` on a session list: a substring of the label or id.
1287pub const PARAM_TEXT: &str = "text";
1288/// `?root=` on a session list: the root of the session tree.
1289pub const PARAM_ROOT: &str = "root";
1290/// `?label_prefix=` on a session list: the start of the label.
1291pub const PARAM_LABEL_PREFIX: &str = "label_prefix";
1292/// `?surface=` on session links: one link surface.
1293pub const PARAM_SURFACE: &str = "surface";
1294/// `?after=` on session changes: the last change sequence already seen.
1295pub const PARAM_AFTER: &str = "after";
1296/// `?total=` on a session list: also count every match.
1297pub const PARAM_TOTAL: &str = "total";
1298/// `?fixed_frontier=` on session events: pin the fold frontier of the first
1299/// page for a historical walk.
1300pub const PARAM_FIXED_FRONTIER: &str = "fixed_frontier";
1301/// `?history_limit=` on session state: the most history rows to return.
1302pub const PARAM_HISTORY_LIMIT: &str = "history_limit";
1303/// `?expires_at_micros=` on a KV `PUT`: absolute expiry, epoch microseconds.
1304pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
1305/// `?expect_version=` on a KV compare-and-swap: apply only if the key holds
1306/// this exact version.
1307pub const PARAM_EXPECT_VERSION: &str = "expect_version";
1308/// `?expect_absent=` on a KV compare-and-swap: apply only if the key is absent
1309/// (create-if-absent).
1310pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
1311
1312/// Response header on `GET /agdx/kv/{namespace}/{key}` carrying the entry's
1313/// absolute expiry (epoch microseconds) as a decimal string. The value itself
1314/// rides the raw response body, so this header carries the one piece of
1315/// out-of-band metadata a single-key read needs. Owned here so the name is a
1316/// wire constant rather than an unscoped string. Absent means no expiry.
1317pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
1318
1319/// `GET /agdx/projections` filters. Every field is optional, and an absent field is
1320/// omitted from the query string (no empty `topic=`). Field names are the
1321/// `PARAM_*` consts verbatim, so the client serializer and the server parser
1322/// share one spelling.
1323#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1324pub struct ProjectionListQuery {
1325    #[serde(default, skip_serializing_if = "Option::is_none")]
1326    pub topic: Option<String>,
1327    #[serde(default, skip_serializing_if = "Option::is_none")]
1328    pub name_contains: Option<String>,
1329    #[serde(default, skip_serializing_if = "Option::is_none")]
1330    pub id_prefix: Option<String>,
1331    /// One substring matched against the projection name OR id, for a console
1332    /// with a single filter box. Composes (AND) with `name_contains`/`id_prefix`.
1333    #[serde(default, skip_serializing_if = "Option::is_none")]
1334    pub search: Option<String>,
1335}
1336
1337/// `GET /agdx/schemas` filters. `name_contains` is the substring filter on a
1338/// schema's optional name, the same spelling as the projection list, so the two
1339/// list surfaces share one filter vocabulary.
1340#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1341pub struct SchemaListQuery {
1342    #[serde(default, skip_serializing_if = "Option::is_none")]
1343    pub name_contains: Option<String>,
1344    /// The stream whose registry is listed. Absent lists the deployment-wide
1345    /// registry.
1346    #[serde(default, skip_serializing_if = "Option::is_none")]
1347    pub stream: Option<String>,
1348}
1349
1350/// `?stream=` on `GET`/`DELETE /agdx/schemas/{id}` and
1351/// `POST /agdx/schemas/{id}/decode`: the stream whose registry holds the id.
1352/// Absent addresses the deployment-wide registry.
1353#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1354pub struct SchemaQuery {
1355    #[serde(default, skip_serializing_if = "Option::is_none")]
1356    pub stream: Option<String>,
1357}
1358
1359/// `?operation_id=` on the HTTP managed writes: the projection, binding, graph,
1360/// and schema-drop control writes, the key-value writes, and the fork writes.
1361/// It names one logical write, so a re-sent request with the same id applies
1362/// once. The value is the decimal text of a non-zero `u128`, the same text the
1363/// filter routes use. A write without it gets a fresh id per request.
1364#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1365pub struct OperationQuery {
1366    #[serde(default, skip_serializing_if = "Option::is_none")]
1367    pub operation_id: Option<String>,
1368}
1369
1370impl OperationQuery {
1371    /// The caller's operation id, or `None` when the request carries none.
1372    pub fn operation_id(&self) -> Result<Option<NonZeroU128>, InvalidOperationId> {
1373        self.operation_id
1374            .as_deref()
1375            .map(|text| {
1376                // `u128::from_str` accepts a leading `+`, which no client sends.
1377                if !text.bytes().all(|byte| byte.is_ascii_digit()) {
1378                    return Err(InvalidOperationId);
1379                }
1380                text.parse::<NonZeroU128>().map_err(|_| InvalidOperationId)
1381            })
1382            .transpose()
1383    }
1384}
1385
1386impl From<NonZeroU128> for OperationQuery {
1387    fn from(operation_id: NonZeroU128) -> Self {
1388        Self {
1389            operation_id: Some(operation_id.to_string()),
1390        }
1391    }
1392}
1393
1394/// An `?operation_id=` that is not the decimal text of a non-zero `u128`.
1395#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
1396#[error("operation_id must be a non-zero decimal u128")]
1397pub struct InvalidOperationId;
1398
1399/// `GET /agdx/filters` query parameters.
1400#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1401pub struct FilterListQuery {
1402    #[serde(default, skip_serializing_if = "Option::is_none")]
1403    pub name_contains: Option<String>,
1404    #[serde(default, skip_serializing_if = "Option::is_none")]
1405    pub state: Option<crate::filter::FilterState>,
1406    /// Only filters with a lower id, for stable paging.
1407    #[serde(default, skip_serializing_if = "Option::is_none")]
1408    pub before_id: Option<u32>,
1409    #[serde(default, skip_serializing_if = "Option::is_none")]
1410    pub page: Option<u32>,
1411    #[serde(default, skip_serializing_if = "Option::is_none")]
1412    pub page_size: Option<u32>,
1413}
1414
1415/// `GET /agdx/filters/{id}/revisions` query parameters.
1416#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1417pub struct FilterRevisionListQuery {
1418    #[serde(default, skip_serializing_if = "Option::is_none")]
1419    pub page: Option<u32>,
1420    #[serde(default, skip_serializing_if = "Option::is_none")]
1421    pub page_size: Option<u32>,
1422}
1423
1424/// `GET /agdx/filter-bindings` query parameters.
1425#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1426pub struct FilterBindingListQuery {
1427    #[serde(default, skip_serializing_if = "Option::is_none")]
1428    pub filter_id: Option<u32>,
1429    #[serde(default, skip_serializing_if = "Option::is_none")]
1430    pub stream: Option<String>,
1431    #[serde(default, skip_serializing_if = "Option::is_none")]
1432    pub topic: Option<String>,
1433    #[serde(default, skip_serializing_if = "Option::is_none")]
1434    pub page: Option<u32>,
1435    #[serde(default, skip_serializing_if = "Option::is_none")]
1436    pub page_size: Option<u32>,
1437}
1438
1439/// `POST /agdx/filters/test` body. The sample payload is given either as
1440/// base64url bytes or as UTF-8 text, never both.
1441#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1442pub struct FilterTestBody {
1443    pub filter: crate::filter::FilterRef,
1444    #[serde(default, skip_serializing_if = "Option::is_none")]
1445    pub payload_base64: Option<String>,
1446    #[serde(default, skip_serializing_if = "Option::is_none")]
1447    pub payload_text: Option<String>,
1448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1449    pub headers: Vec<crate::filter::FilterHeader>,
1450}
1451
1452/// `GET /agdx/kv/{namespace}` scan filters. The byte-valued bounds
1453/// (`prefix`/`start`/`end`/`key_contains`) are base64url, like every binary
1454/// value on this surface.
1455#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1456pub struct KvScanQuery {
1457    #[serde(default, skip_serializing_if = "Option::is_none")]
1458    pub prefix: Option<String>,
1459    #[serde(default, skip_serializing_if = "Option::is_none")]
1460    pub start: Option<String>,
1461    #[serde(default, skip_serializing_if = "Option::is_none")]
1462    pub end: Option<String>,
1463    #[serde(default, skip_serializing_if = "Option::is_none")]
1464    pub key_contains: Option<String>,
1465    /// The conversation lens: keep only rows the given conversation wrote (the
1466    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
1467    /// this on each record, so a scan of a memory namespace narrows to one
1468    /// conversation. Plain text, not base64url, since a conversation id is text.
1469    #[serde(default, skip_serializing_if = "Option::is_none")]
1470    pub conversation: Option<String>,
1471    #[serde(default, skip_serializing_if = "Option::is_none")]
1472    pub limit: Option<usize>,
1473    #[serde(default, skip_serializing_if = "Option::is_none")]
1474    pub cursor: Option<String>,
1475}
1476
1477/// `PUT /agdx/kv/{namespace}/{key}` query: an optional absolute expiry.
1478#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1479pub struct KvPutQuery {
1480    #[serde(default, skip_serializing_if = "Option::is_none")]
1481    pub expires_at_micros: Option<u64>,
1482}
1483
1484/// `GET /agdx/graph/{name}/neighbors/{node}` query: the traversal direction
1485/// (`out`, `in`, or `both`, omitted for the default `out`), an optional edge-type
1486/// filter, the hop depth (omitted for the default one hop), and a result limit
1487/// (omitted for the backend ceiling). One struct shared by the typed client and
1488/// the server route, so the two cannot drift.
1489#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1490pub struct GraphNeighborsQuery {
1491    #[serde(default, skip_serializing_if = "Option::is_none")]
1492    pub dir: Option<String>,
1493    #[serde(default, skip_serializing_if = "Option::is_none")]
1494    pub edge_type: Option<String>,
1495    #[serde(default, skip_serializing_if = "Option::is_none")]
1496    pub depth: Option<u32>,
1497    #[serde(default, skip_serializing_if = "Option::is_none")]
1498    pub limit: Option<usize>,
1499    /// Valid-time "as of" read (epoch micros): only edges valid at this instant.
1500    #[serde(default, skip_serializing_if = "Option::is_none")]
1501    pub as_of: Option<u64>,
1502    /// Restrict to elements a given conversation asserted (the text form of its
1503    /// `gen_ai.conversation.id`). Omitted reads the whole graph. The conversation
1504    /// lens over the neighbors route.
1505    #[serde(default, skip_serializing_if = "Option::is_none")]
1506    pub conversation: Option<String>,
1507}
1508
1509/// Filters for `GET /agdx/sessions/{stream}`. The stream is the path
1510/// segment. A `limit` of zero or none leaves the page size to the server.
1511#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1512pub struct SessionsQuery {
1513    #[serde(default, skip_serializing_if = "Option::is_none")]
1514    pub status: Option<String>,
1515    #[serde(default, skip_serializing_if = "Option::is_none")]
1516    pub root: Option<String>,
1517    #[serde(default, skip_serializing_if = "Option::is_none")]
1518    pub label_prefix: Option<String>,
1519    #[serde(default, skip_serializing_if = "Option::is_none")]
1520    pub agent: Option<String>,
1521    #[serde(default, skip_serializing_if = "Option::is_none")]
1522    pub text: Option<String>,
1523    #[serde(default, skip_serializing_if = "Option::is_none")]
1524    pub cursor: Option<String>,
1525    #[serde(default, skip_serializing_if = "Option::is_none")]
1526    pub limit: Option<u32>,
1527    #[serde(default, rename = "total")]
1528    pub want_total: bool,
1529}
1530
1531/// Query of `GET /agdx/sessions/{stream}/{id}/events`. A `limit` of zero or
1532/// none leaves the page size to the server.
1533#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1534pub struct SessionEventsQuery {
1535    #[serde(default, skip_serializing_if = "Option::is_none")]
1536    pub cursor: Option<String>,
1537    #[serde(default, skip_serializing_if = "Option::is_none")]
1538    pub limit: Option<u32>,
1539    #[serde(default)]
1540    pub fixed_frontier: bool,
1541}
1542
1543/// Query of `GET /agdx/sessions/{stream}/{id}/state`. A `history_limit` of
1544/// zero or none leaves the history size to the server.
1545#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1546pub struct SessionStateQuery {
1547    #[serde(default, skip_serializing_if = "Option::is_none")]
1548    pub history_limit: Option<u32>,
1549}
1550
1551/// Query of `GET /agdx/sessions/{stream}/{id}/links`.
1552#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1553pub struct SessionLinksQuery {
1554    #[serde(default, skip_serializing_if = "Option::is_none")]
1555    pub surface: Option<String>,
1556}
1557
1558/// Query of `GET /agdx/sessions/{stream}/changes`. A `limit` of zero or none
1559/// leaves the page size to the server.
1560#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1561pub struct SessionChangesQuery {
1562    #[serde(default)]
1563    pub after: u64,
1564    #[serde(default, skip_serializing_if = "Option::is_none")]
1565    pub limit: Option<u32>,
1566    #[serde(default, skip_serializing_if = "Option::is_none")]
1567    pub series: Option<u64>,
1568}
1569
1570/// `PUT /agdx/kv/{namespace}/{key}/cas` query: the compare-and-swap precondition
1571/// plus an optional expiry. Exactly one of `expect_version` (match the held
1572/// version) or `expect_absent` (create-if-absent) is set, mirroring the binary
1573/// `CasExpect`. The value rides the raw request body.
1574#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1575pub struct KvCasQuery {
1576    #[serde(default, skip_serializing_if = "Option::is_none")]
1577    pub expect_version: Option<u64>,
1578    #[serde(default, skip_serializing_if = "Option::is_none")]
1579    pub expect_absent: Option<bool>,
1580    #[serde(default, skip_serializing_if = "Option::is_none")]
1581    pub expires_at_micros: Option<u64>,
1582}
1583
1584/// `PUT /agdx/kv/{namespace}/{key}/cas` reply on success: the new version the
1585/// committed write took.
1586#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1587pub struct CasCommittedView {
1588    pub version: u64,
1589}
1590
1591#[cfg(test)]
1592mod tests {
1593    use super::*;
1594
1595    #[test]
1596    fn given_path_builders_when_rendered_then_should_match_the_router() {
1597        assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1598        assert_eq!(
1599            projection_path("reading.v1"),
1600            "/agdx/projections/reading.v1"
1601        );
1602        assert_eq!(schema_path(7), "/agdx/schemas/7");
1603        assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1604        assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1605        assert_eq!(
1606            kv_entry_path("sessions", "dXNlcjo0Mg"),
1607            "/agdx/kv/sessions/dXNlcjo0Mg"
1608        );
1609        assert_eq!(
1610            kv_cas_path("sessions", "dXNlcjo0Mg"),
1611            "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1612        );
1613        assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1614        assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1615        assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1616    }
1617
1618    #[test]
1619    fn given_an_operation_id_when_rendered_and_parsed_then_should_round_trip() {
1620        let operation_id = NonZeroU128::new(u128::MAX).expect("non-zero");
1621        let query = OperationQuery::from(operation_id);
1622        assert_eq!(
1623            query.operation_id.as_deref(),
1624            Some("340282366920938463463374607431768211455")
1625        );
1626        assert_eq!(query.operation_id(), Ok(Some(operation_id)));
1627        assert_eq!(OperationQuery::default().operation_id(), Ok(None));
1628    }
1629
1630    #[test]
1631    fn given_a_malformed_operation_id_when_parsed_then_should_refuse_it() {
1632        for text in [
1633            "",
1634            "0",
1635            "+7",
1636            "-7",
1637            " 7",
1638            "0x7",
1639            "7.0",
1640            "340282366920938463463374607431768211456",
1641        ] {
1642            let query = OperationQuery {
1643                operation_id: Some(text.to_owned()),
1644            };
1645            assert_eq!(query.operation_id(), Err(InvalidOperationId), "{text:?}");
1646        }
1647    }
1648
1649    #[test]
1650    fn given_stream_scoped_names_when_rendered_then_should_encode_each_as_one_segment() {
1651        assert_eq!(
1652            kv_namespace_path("stream:acme/support"),
1653            "/agdx/kv/stream%3Aacme%2Fsupport"
1654        );
1655        assert_eq!(
1656            kv_cas_path("stream:acme/support", "dXNlcjo0Mg"),
1657            "/agdx/kv/stream%3Aacme%2Fsupport/dXNlcjo0Mg/cas"
1658        );
1659        assert_eq!(
1660            graph_neighbors_path("stream:acme/kg", "a/b"),
1661            "/agdx/graph/stream%3Aacme%2Fkg/neighbors/a%2Fb"
1662        );
1663        assert_eq!(
1664            graph_query_path("stream:acme/kg"),
1665            "/agdx/graph/stream%3Aacme%2Fkg/query"
1666        );
1667        assert_eq!(
1668            graph_path("stream:acme/kg"),
1669            "/agdx/graphs/stream%3Aacme%2Fkg"
1670        );
1671        assert_eq!(
1672            projection_path("stream:acme/readings"),
1673            "/agdx/projections/stream%3Aacme%2Freadings"
1674        );
1675    }
1676
1677    #[test]
1678    fn given_the_sessions_feature_when_built_from_versions_then_should_advertise_sessions() {
1679        let mut versions = OpVersions::new(1, 1, 1, 1);
1680        assert!(!Capabilities::from_versions(true, versions).sessions);
1681        versions.features |= crate::hello::feature::SESSIONS;
1682        assert!(Capabilities::from_versions(true, versions).sessions);
1683        assert!(!Capabilities::from_versions(false, versions).sessions);
1684    }
1685
1686    #[test]
1687    fn given_capabilities_when_constructed_then_extended_features_default_off() {
1688        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1689        assert!(
1690            caps.query.available && caps.kv.available && caps.fork,
1691            "core surfaces track enabled"
1692        );
1693        assert!(
1694            !caps.kv.cas
1695                && !caps.kv.cas_fenced
1696                && !caps.kv.fenced_leases
1697                && caps.query.consistency == Consistency::Eventual,
1698            "sub-features must be opt-in, never on by default"
1699        );
1700        let opted = caps
1701            .with_kv_cas(true)
1702            .with_query_consistency(Consistency::ReadYourWrites);
1703        assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1704    }
1705
1706    #[test]
1707    fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
1708        let versions =
1709            OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
1710        let caps = Capabilities::from_versions(true, versions);
1711        assert!(caps.kv.fenced_leases);
1712        assert!(
1713            caps.kv.cas_fenced,
1714            "the full fenced-lease contract subsumes fenced CAS"
1715        );
1716    }
1717
1718    #[test]
1719    fn given_group_reads_without_evaluation_when_building_http_capabilities_then_should_keep_catalog_available()
1720     {
1721        let versions = OpVersions::new(1, 1, 1, 1)
1722            .with_filter(crate::codes::FILTER_OP_VERSION)
1723            .with_features(crate::hello::feature::GROUP_POLICY_READS);
1724        let caps = Capabilities::from_versions(true, versions);
1725        assert!(caps.filters.group_policy_reads);
1726        assert!(caps.filters.catalog && caps.filters.managed_groups);
1727        assert!(!caps.filters.native && !caps.filters.preview);
1728        assert_eq!(caps.filters.evaluator_version, 0);
1729        assert!(caps.filters.codecs.is_empty());
1730        let absent = Capabilities::from_versions(false, OpVersions::new(1, 1, 1, 1));
1731        assert!(!absent.filters.group_policy_reads && !absent.filters.catalog);
1732    }
1733
1734    #[test]
1735    fn given_destination_versions_when_building_capabilities_then_the_core_surface_is_available() {
1736        use crate::codes::CHECKPOINT_OP_VERSION;
1737        use crate::hello::feature;
1738
1739        let versions = OpVersions::new(2, 2, 1, 1)
1740            .with_checkpoint(CHECKPOINT_OP_VERSION)
1741            .with_features(feature::DESTINATIONS);
1742        let caps = Capabilities::from_versions(true, versions);
1743
1744        assert!(caps.destinations.available);
1745        assert!(caps.destinations.lifecycle);
1746        assert!(caps.destinations.checkpoint_status);
1747        assert!(caps.destinations.query_routes);
1748        assert!(!caps.destinations.table_schema);
1749    }
1750
1751    #[test]
1752    fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1753        use crate::destination::BackendResourceId;
1754        use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1755        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1756            BackendDescriptor::new(
1757                BackendResourceId::from_u128(1),
1758                BackendMode::Operational,
1759                "Embedded",
1760                BackendImplementation {
1761                    kind: "embedded".to_owned(),
1762                    version: "1.0.0".to_owned(),
1763                },
1764                1,
1765                1,
1766            ),
1767            BackendDescriptor::new(
1768                BackendResourceId::from_u128(2),
1769                BackendMode::Lakehouse,
1770                "Warehouse",
1771                BackendImplementation {
1772                    kind: "columnar".to_owned(),
1773                    version: "1.0.0".to_owned(),
1774                },
1775                1,
1776                1,
1777            ),
1778        ]);
1779        let json = serde_json::to_string(&caps).expect("serializes");
1780        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1781        assert_eq!(back.backends.len(), 2);
1782        assert_eq!(
1783            back.backends[1].resource_id,
1784            BackendResourceId::from_u128(2)
1785        );
1786        assert_eq!(back.backends[1].implementation.kind, "columnar");
1787
1788        // No advertised backends is omitted on the wire, so a pre-backends
1789        // capabilities reply stays byte-identical.
1790        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1791        let json = serde_json::to_string(&plain).expect("json");
1792        assert!(!json.contains("backends"), "empty backends omitted: {json}");
1793    }
1794
1795    #[test]
1796    fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1797        let custom = WireTopology {
1798            ops_stream: "custom-ops".to_owned(),
1799            ..WireTopology::default()
1800        };
1801        let caps =
1802            Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1803        let json = serde_json::to_string(&caps).expect("serializes");
1804        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1805        assert_eq!(back.topology, Some(custom));
1806
1807        // No advertised topology (the default) is omitted on the wire, so a
1808        // pre-topology capabilities reply stays byte-identical.
1809        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1810        let json = serde_json::to_string(&plain).expect("json");
1811        assert!(
1812            !json.contains("topology"),
1813            "absent topology omitted: {json}"
1814        );
1815    }
1816
1817    #[test]
1818    fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1819        let body = ErrorBody::from(&QueryError::IndexNotFound("readings".to_owned()));
1820        assert_eq!(body.code, ResultCode::NotFound);
1821        assert_eq!(body.http_status(), 404);
1822        assert!(body.message.contains("readings"));
1823        // Round-trips as JSON, the form the HTTP surface serves it in.
1824        let json = serde_json::to_string(&body).expect("serializes");
1825        let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1826        assert_eq!(back, body);
1827    }
1828
1829    #[test]
1830    #[cfg(feature = "http-client")]
1831    fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1832        let query = KvScanQuery {
1833            prefix: Some("dXNlcjo".to_owned()),
1834            limit: Some(50),
1835            ..Default::default()
1836        };
1837        let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1838        assert_eq!(encoded, "prefix=dXNlcjo&limit=50");
1839        // Field names are the PARAM_* consts verbatim.
1840        assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1841        assert!(encoded.contains(&format!("{PARAM_LIMIT}=")));
1842    }
1843
1844    #[test]
1845    #[cfg(feature = "http-client")]
1846    fn given_names_with_reserved_characters_when_building_a_binding_path_then_should_encode_each_segment()
1847     {
1848        assert_eq!(
1849            filter_binding_path("orbit", "fleet/changes", "desk?x#1"),
1850            "/agdx/filter-bindings/orbit/fleet%2Fchanges/desk%3Fx%231"
1851        );
1852    }
1853
1854    #[cfg(feature = "http-client")]
1855    #[test]
1856    fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1857        let projections = ProjectionListQuery {
1858            name_contains: Some("reading".to_owned()),
1859            id_prefix: Some("reading.".to_owned()),
1860            ..Default::default()
1861        };
1862        let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1863        assert_eq!(encoded, "name_contains=reading&id_prefix=reading.");
1864        assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1865        assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1866
1867        let schemas = SchemaListQuery {
1868            name_contains: Some("Reading".to_owned()),
1869            stream: None,
1870        };
1871        assert_eq!(
1872            serde_urlencoded::to_string(&schemas).expect("encodes"),
1873            "name_contains=Reading"
1874        );
1875        let scoped = SchemaListQuery {
1876            name_contains: None,
1877            stream: Some("acme".to_owned()),
1878        };
1879        assert_eq!(
1880            serde_urlencoded::to_string(&scoped).expect("encodes"),
1881            format!("{PARAM_STREAM}=acme")
1882        );
1883        assert_eq!(
1884            serde_urlencoded::to_string(SchemaQuery::default()).expect("encodes"),
1885            ""
1886        );
1887        assert_eq!(
1888            serde_urlencoded::to_string(SchemaQuery {
1889                stream: Some("acme".to_owned()),
1890            })
1891            .expect("encodes"),
1892            format!("{PARAM_STREAM}=acme")
1893        );
1894    }
1895
1896    #[test]
1897    fn given_a_register_schema_body_without_a_stream_when_encoded_then_should_omit_it() {
1898        let body = RegisterSchemaBody {
1899            source: crate::control::SchemaSource::JsonSchema {
1900                schema: "{}".to_owned(),
1901            },
1902            name: None,
1903            version: None,
1904            stream: None,
1905        };
1906        let json = serde_json::to_string(&body).expect("encodes");
1907        assert!(
1908            !json.contains("stream"),
1909            "an unset stream is omitted: {json}"
1910        );
1911        let scoped: RegisterSchemaBody = serde_json::from_str(
1912            r#"{"source":{"kind":"json_schema","schema":"{}"},"stream":"acme"}"#,
1913        )
1914        .expect("decodes");
1915        assert_eq!(scoped.stream.as_deref(), Some("acme"));
1916    }
1917}