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