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/// Request header an intermediary sets on a managed HTTP write to name the end
1322/// user it writes for. The streaming server forwards it to the backend, which
1323/// records it beside the authenticated user and never trusts it.
1324pub const ON_BEHALF_OF_HEADER: &str = "agdx-on-behalf-of";
1325
1326/// `GET /agdx/projections` filters. Every field is optional, and an absent field is
1327/// omitted from the query string (no empty `topic=`). Field names are the
1328/// `PARAM_*` consts verbatim, so the client serializer and the server parser
1329/// share one spelling.
1330#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1331pub struct ProjectionListQuery {
1332    #[serde(default, skip_serializing_if = "Option::is_none")]
1333    pub topic: Option<String>,
1334    #[serde(default, skip_serializing_if = "Option::is_none")]
1335    pub name_contains: Option<String>,
1336    #[serde(default, skip_serializing_if = "Option::is_none")]
1337    pub id_prefix: Option<String>,
1338    /// One substring matched against the projection name OR id, for a console
1339    /// with a single filter box. Composes (AND) with `name_contains`/`id_prefix`.
1340    #[serde(default, skip_serializing_if = "Option::is_none")]
1341    pub search: Option<String>,
1342}
1343
1344/// `GET /agdx/schemas` filters. `name_contains` is the substring filter on a
1345/// schema's optional name, the same spelling as the projection list, so the two
1346/// list surfaces share one filter vocabulary.
1347#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1348pub struct SchemaListQuery {
1349    #[serde(default, skip_serializing_if = "Option::is_none")]
1350    pub name_contains: Option<String>,
1351    /// The stream whose registry is listed. Absent lists the deployment-wide
1352    /// registry.
1353    #[serde(default, skip_serializing_if = "Option::is_none")]
1354    pub stream: Option<String>,
1355}
1356
1357/// `?stream=` on `GET`/`DELETE /agdx/schemas/{id}` and
1358/// `POST /agdx/schemas/{id}/decode`: the stream whose registry holds the id.
1359/// Absent addresses the deployment-wide registry.
1360#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1361pub struct SchemaQuery {
1362    #[serde(default, skip_serializing_if = "Option::is_none")]
1363    pub stream: Option<String>,
1364}
1365
1366/// `?operation_id=` on the HTTP managed writes: the projection, binding, graph,
1367/// and schema-drop control writes, the key-value writes, and the fork writes.
1368/// It names one logical write, so a re-sent request with the same id applies
1369/// once. The value is the decimal text of a non-zero `u128`, the same text the
1370/// filter routes use. A write without it gets a fresh id per request.
1371#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1372pub struct OperationQuery {
1373    #[serde(default, skip_serializing_if = "Option::is_none")]
1374    pub operation_id: Option<String>,
1375}
1376
1377impl OperationQuery {
1378    /// The caller's operation id, or `None` when the request carries none.
1379    pub fn operation_id(&self) -> Result<Option<NonZeroU128>, InvalidOperationId> {
1380        self.operation_id
1381            .as_deref()
1382            .map(|text| {
1383                // `u128::from_str` accepts a leading `+`, which no client sends.
1384                if !text.bytes().all(|byte| byte.is_ascii_digit()) {
1385                    return Err(InvalidOperationId);
1386                }
1387                text.parse::<NonZeroU128>().map_err(|_| InvalidOperationId)
1388            })
1389            .transpose()
1390    }
1391}
1392
1393impl From<NonZeroU128> for OperationQuery {
1394    fn from(operation_id: NonZeroU128) -> Self {
1395        Self {
1396            operation_id: Some(operation_id.to_string()),
1397        }
1398    }
1399}
1400
1401/// An `?operation_id=` that is not the decimal text of a non-zero `u128`.
1402#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
1403#[error("operation_id must be a non-zero decimal u128")]
1404pub struct InvalidOperationId;
1405
1406/// `GET /agdx/filters` query parameters.
1407#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1408pub struct FilterListQuery {
1409    #[serde(default, skip_serializing_if = "Option::is_none")]
1410    pub name_contains: Option<String>,
1411    #[serde(default, skip_serializing_if = "Option::is_none")]
1412    pub state: Option<crate::filter::FilterState>,
1413    /// Only filters with a lower id, for stable paging.
1414    #[serde(default, skip_serializing_if = "Option::is_none")]
1415    pub before_id: Option<u32>,
1416    /// The owner filter is the five fields of a group identity, all or none.
1417    #[serde(default, skip_serializing_if = "Option::is_none")]
1418    pub owner_stream_id: Option<u32>,
1419    #[serde(default, skip_serializing_if = "Option::is_none")]
1420    pub owner_stream_created_at_micros: Option<u64>,
1421    #[serde(default, skip_serializing_if = "Option::is_none")]
1422    pub owner_topic_id: Option<u32>,
1423    #[serde(default, skip_serializing_if = "Option::is_none")]
1424    pub owner_topic_created_at_micros: Option<u64>,
1425    #[serde(default, skip_serializing_if = "Option::is_none")]
1426    pub owner_group_id: Option<u64>,
1427    #[serde(default, skip_serializing_if = "Option::is_none")]
1428    pub page: Option<u32>,
1429    #[serde(default, skip_serializing_if = "Option::is_none")]
1430    pub page_size: Option<u32>,
1431}
1432
1433impl FilterListQuery {
1434    /// The owner filter, refused unless all five identity fields are set or none.
1435    pub fn owner(&self) -> Result<Option<FilterGroupIdentity>, InvalidError> {
1436        match (
1437            self.owner_stream_id,
1438            self.owner_stream_created_at_micros,
1439            self.owner_topic_id,
1440            self.owner_topic_created_at_micros,
1441            self.owner_group_id,
1442        ) {
1443            (None, None, None, None, None) => Ok(None),
1444            (
1445                Some(stream_id),
1446                Some(stream_created_at_micros),
1447                Some(topic_id),
1448                Some(topic_created_at_micros),
1449                Some(group_id),
1450            ) => Ok(Some(FilterGroupIdentity {
1451                stream_id,
1452                stream_created_at_micros,
1453                topic_id,
1454                topic_created_at_micros,
1455                group_id,
1456            })),
1457            _ => Err(InvalidError::new(
1458                "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",
1459            )),
1460        }
1461    }
1462}
1463
1464impl From<FilterGroupIdentity> for FilterListQuery {
1465    fn from(identity: FilterGroupIdentity) -> Self {
1466        Self {
1467            owner_stream_id: Some(identity.stream_id),
1468            owner_stream_created_at_micros: Some(identity.stream_created_at_micros),
1469            owner_topic_id: Some(identity.topic_id),
1470            owner_topic_created_at_micros: Some(identity.topic_created_at_micros),
1471            owner_group_id: Some(identity.group_id),
1472            ..Self::default()
1473        }
1474    }
1475}
1476
1477/// `GET /agdx/filters/{id}/revisions` query parameters.
1478#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1479pub struct FilterRevisionListQuery {
1480    #[serde(default, skip_serializing_if = "Option::is_none")]
1481    pub page: Option<u32>,
1482    #[serde(default, skip_serializing_if = "Option::is_none")]
1483    pub page_size: Option<u32>,
1484}
1485
1486/// `GET /agdx/filter-bindings` query parameters.
1487#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1488pub struct FilterBindingListQuery {
1489    #[serde(default, skip_serializing_if = "Option::is_none")]
1490    pub filter_id: Option<u32>,
1491    #[serde(default, skip_serializing_if = "Option::is_none")]
1492    pub stream: Option<String>,
1493    #[serde(default, skip_serializing_if = "Option::is_none")]
1494    pub topic: Option<String>,
1495    #[serde(default, skip_serializing_if = "Option::is_none")]
1496    pub page: Option<u32>,
1497    #[serde(default, skip_serializing_if = "Option::is_none")]
1498    pub page_size: Option<u32>,
1499}
1500
1501/// `POST /agdx/filters/test` body. The sample payload is given either as
1502/// base64url bytes or as UTF-8 text, never both.
1503#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1504pub struct FilterTestBody {
1505    pub filter: crate::filter::FilterRef,
1506    #[serde(default, skip_serializing_if = "Option::is_none")]
1507    pub payload_base64: Option<String>,
1508    #[serde(default, skip_serializing_if = "Option::is_none")]
1509    pub payload_text: Option<String>,
1510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1511    pub headers: Vec<crate::filter::FilterHeader>,
1512}
1513
1514/// `GET /agdx/kv/{namespace}` scan filters. The byte-valued bounds
1515/// (`prefix`/`start`/`end`/`key_contains`) are base64url, like every binary
1516/// value on this surface.
1517#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1518pub struct KvScanQuery {
1519    #[serde(default, skip_serializing_if = "Option::is_none")]
1520    pub prefix: Option<String>,
1521    #[serde(default, skip_serializing_if = "Option::is_none")]
1522    pub start: Option<String>,
1523    #[serde(default, skip_serializing_if = "Option::is_none")]
1524    pub end: Option<String>,
1525    #[serde(default, skip_serializing_if = "Option::is_none")]
1526    pub key_contains: Option<String>,
1527    /// The conversation lens: keep only rows the given conversation wrote (the
1528    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
1529    /// this on each record, so a scan of a memory namespace narrows to one
1530    /// conversation. Plain text, not base64url, since a conversation id is text.
1531    #[serde(default, skip_serializing_if = "Option::is_none")]
1532    pub conversation: Option<String>,
1533    #[serde(default, skip_serializing_if = "Option::is_none")]
1534    pub limit: Option<usize>,
1535    #[serde(default, skip_serializing_if = "Option::is_none")]
1536    pub cursor: Option<String>,
1537}
1538
1539/// `PUT /agdx/kv/{namespace}/{key}` query: an optional absolute expiry.
1540#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1541pub struct KvPutQuery {
1542    #[serde(default, skip_serializing_if = "Option::is_none")]
1543    pub expires_at_micros: Option<u64>,
1544}
1545
1546/// `GET /agdx/graph/{name}/neighbors/{node}` query: the traversal direction
1547/// (`out`, `in`, or `both`, omitted for the default `out`), an optional edge-type
1548/// filter, the hop depth (omitted for the default one hop), and a result limit
1549/// (omitted for the backend ceiling). One struct shared by the typed client and
1550/// the server route, so the two cannot drift.
1551#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1552pub struct GraphNeighborsQuery {
1553    #[serde(default, skip_serializing_if = "Option::is_none")]
1554    pub dir: Option<String>,
1555    #[serde(default, skip_serializing_if = "Option::is_none")]
1556    pub edge_type: Option<String>,
1557    #[serde(default, skip_serializing_if = "Option::is_none")]
1558    pub depth: Option<u32>,
1559    #[serde(default, skip_serializing_if = "Option::is_none")]
1560    pub limit: Option<usize>,
1561    /// Valid-time "as of" read (epoch micros): only edges valid at this instant.
1562    #[serde(default, skip_serializing_if = "Option::is_none")]
1563    pub as_of: Option<u64>,
1564    /// Restrict to elements a given conversation asserted (the text form of its
1565    /// `gen_ai.conversation.id`). Omitted reads the whole graph. The conversation
1566    /// lens over the neighbors route.
1567    #[serde(default, skip_serializing_if = "Option::is_none")]
1568    pub conversation: Option<String>,
1569}
1570
1571/// Filters for `GET /agdx/sessions/{stream}`. The stream is the path
1572/// segment. A `limit` of zero or none leaves the page size to the server.
1573#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1574pub struct SessionsQuery {
1575    #[serde(default, skip_serializing_if = "Option::is_none")]
1576    pub status: Option<String>,
1577    #[serde(default, skip_serializing_if = "Option::is_none")]
1578    pub root: Option<String>,
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub label_prefix: Option<String>,
1581    #[serde(default, skip_serializing_if = "Option::is_none")]
1582    pub agent: Option<String>,
1583    #[serde(default, skip_serializing_if = "Option::is_none")]
1584    pub text: Option<String>,
1585    #[serde(default, skip_serializing_if = "Option::is_none")]
1586    pub cursor: Option<String>,
1587    #[serde(default, skip_serializing_if = "Option::is_none")]
1588    pub limit: Option<u32>,
1589    #[serde(default, rename = "total")]
1590    pub want_total: bool,
1591}
1592
1593/// Query of `GET /agdx/sessions/{stream}/{id}/events`. A `limit` of zero or
1594/// none leaves the page size to the server.
1595#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1596pub struct SessionEventsQuery {
1597    #[serde(default, skip_serializing_if = "Option::is_none")]
1598    pub cursor: Option<String>,
1599    #[serde(default, skip_serializing_if = "Option::is_none")]
1600    pub limit: Option<u32>,
1601    #[serde(default)]
1602    pub fixed_frontier: bool,
1603}
1604
1605/// Query of `GET /agdx/sessions/{stream}/{id}/state`. A `history_limit` of
1606/// zero or none leaves the history size to the server.
1607#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1608pub struct SessionStateQuery {
1609    #[serde(default, skip_serializing_if = "Option::is_none")]
1610    pub history_limit: Option<u32>,
1611}
1612
1613/// Query of `GET /agdx/sessions/{stream}/{id}/links`.
1614#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1615pub struct SessionLinksQuery {
1616    #[serde(default, skip_serializing_if = "Option::is_none")]
1617    pub surface: Option<String>,
1618}
1619
1620/// Query of `GET /agdx/sessions/{stream}/changes`. A `limit` of zero or none
1621/// leaves the page size to the server.
1622#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1623pub struct SessionChangesQuery {
1624    #[serde(default)]
1625    pub after: u64,
1626    #[serde(default, skip_serializing_if = "Option::is_none")]
1627    pub limit: Option<u32>,
1628    #[serde(default, skip_serializing_if = "Option::is_none")]
1629    pub series: Option<u64>,
1630}
1631
1632/// `PUT /agdx/kv/{namespace}/{key}/cas` query: the compare-and-swap precondition
1633/// plus an optional expiry. Exactly one of `expect_version` (match the held
1634/// version) or `expect_absent` (create-if-absent) is set, mirroring the binary
1635/// `CasExpect`. The value rides the raw request body.
1636#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1637pub struct KvCasQuery {
1638    #[serde(default, skip_serializing_if = "Option::is_none")]
1639    pub expect_version: Option<u64>,
1640    #[serde(default, skip_serializing_if = "Option::is_none")]
1641    pub expect_absent: Option<bool>,
1642    #[serde(default, skip_serializing_if = "Option::is_none")]
1643    pub expires_at_micros: Option<u64>,
1644}
1645
1646/// `PUT /agdx/kv/{namespace}/{key}/cas` reply on success: the new version the
1647/// committed write took.
1648#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1649pub struct CasCommittedView {
1650    pub version: u64,
1651}
1652
1653#[cfg(test)]
1654mod tests {
1655    use super::*;
1656
1657    #[test]
1658    fn given_a_filter_list_query_when_the_owner_is_complete_or_partial_then_should_resolve_or_refuse()
1659     {
1660        let identity = FilterGroupIdentity {
1661            stream_id: 1,
1662            stream_created_at_micros: 10,
1663            topic_id: 2,
1664            topic_created_at_micros: 20,
1665            group_id: 3,
1666        };
1667        let query = FilterListQuery::from(identity);
1668        assert_eq!(query.owner().expect("complete owner"), Some(identity));
1669        assert_eq!(FilterListQuery::default().owner().expect("no owner"), None);
1670        let partial = FilterListQuery {
1671            owner_group_id: None,
1672            ..query
1673        };
1674        assert!(partial.owner().is_err());
1675    }
1676
1677    #[test]
1678    fn given_path_builders_when_rendered_then_should_match_the_router() {
1679        assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1680        assert_eq!(
1681            projection_path("reading.v1"),
1682            "/agdx/projections/reading.v1"
1683        );
1684        assert_eq!(schema_path(7), "/agdx/schemas/7");
1685        assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1686        assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1687        assert_eq!(
1688            kv_entry_path("sessions", "dXNlcjo0Mg"),
1689            "/agdx/kv/sessions/dXNlcjo0Mg"
1690        );
1691        assert_eq!(
1692            kv_cas_path("sessions", "dXNlcjo0Mg"),
1693            "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1694        );
1695        assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1696        assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1697        assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1698    }
1699
1700    #[test]
1701    fn given_an_operation_id_when_rendered_and_parsed_then_should_round_trip() {
1702        let operation_id = NonZeroU128::new(u128::MAX).expect("non-zero");
1703        let query = OperationQuery::from(operation_id);
1704        assert_eq!(
1705            query.operation_id.as_deref(),
1706            Some("340282366920938463463374607431768211455")
1707        );
1708        assert_eq!(query.operation_id(), Ok(Some(operation_id)));
1709        assert_eq!(OperationQuery::default().operation_id(), Ok(None));
1710    }
1711
1712    #[test]
1713    fn given_a_malformed_operation_id_when_parsed_then_should_refuse_it() {
1714        for text in [
1715            "",
1716            "0",
1717            "+7",
1718            "-7",
1719            " 7",
1720            "0x7",
1721            "7.0",
1722            "340282366920938463463374607431768211456",
1723        ] {
1724            let query = OperationQuery {
1725                operation_id: Some(text.to_owned()),
1726            };
1727            assert_eq!(query.operation_id(), Err(InvalidOperationId), "{text:?}");
1728        }
1729    }
1730
1731    #[test]
1732    fn given_stream_scoped_names_when_rendered_then_should_encode_each_as_one_segment() {
1733        assert_eq!(
1734            kv_namespace_path("stream:acme/support"),
1735            "/agdx/kv/stream%3Aacme%2Fsupport"
1736        );
1737        assert_eq!(
1738            kv_cas_path("stream:acme/support", "dXNlcjo0Mg"),
1739            "/agdx/kv/stream%3Aacme%2Fsupport/dXNlcjo0Mg/cas"
1740        );
1741        assert_eq!(
1742            graph_neighbors_path("stream:acme/kg", "a/b"),
1743            "/agdx/graph/stream%3Aacme%2Fkg/neighbors/a%2Fb"
1744        );
1745        assert_eq!(
1746            graph_query_path("stream:acme/kg"),
1747            "/agdx/graph/stream%3Aacme%2Fkg/query"
1748        );
1749        assert_eq!(
1750            graph_path("stream:acme/kg"),
1751            "/agdx/graphs/stream%3Aacme%2Fkg"
1752        );
1753        assert_eq!(
1754            projection_path("stream:acme/readings"),
1755            "/agdx/projections/stream%3Aacme%2Freadings"
1756        );
1757    }
1758
1759    #[test]
1760    fn given_the_sessions_feature_when_built_from_versions_then_should_advertise_sessions() {
1761        let mut versions = OpVersions::new(1, 1, 1, 1);
1762        assert!(!Capabilities::from_versions(true, versions).sessions);
1763        versions.features |= crate::hello::feature::SESSIONS;
1764        assert!(Capabilities::from_versions(true, versions).sessions);
1765        assert!(!Capabilities::from_versions(false, versions).sessions);
1766    }
1767
1768    #[test]
1769    fn given_capabilities_when_constructed_then_extended_features_default_off() {
1770        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1771        assert!(
1772            caps.query.available && caps.kv.available && caps.fork,
1773            "core surfaces track enabled"
1774        );
1775        assert!(
1776            !caps.kv.cas
1777                && !caps.kv.cas_fenced
1778                && !caps.kv.fenced_leases
1779                && caps.query.consistency == Consistency::Eventual,
1780            "sub-features must be opt-in, never on by default"
1781        );
1782        let opted = caps
1783            .with_kv_cas(true)
1784            .with_query_consistency(Consistency::ReadYourWrites);
1785        assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1786    }
1787
1788    #[test]
1789    fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
1790        let versions =
1791            OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
1792        let caps = Capabilities::from_versions(true, versions);
1793        assert!(caps.kv.fenced_leases);
1794        assert!(
1795            caps.kv.cas_fenced,
1796            "the full fenced-lease contract subsumes fenced CAS"
1797        );
1798    }
1799
1800    #[test]
1801    fn given_group_reads_without_evaluation_when_building_http_capabilities_then_should_keep_catalog_available()
1802     {
1803        let versions = OpVersions::new(1, 1, 1, 1)
1804            .with_filter(crate::codes::FILTER_OP_VERSION)
1805            .with_features(crate::hello::feature::GROUP_POLICY_READS);
1806        let caps = Capabilities::from_versions(true, versions);
1807        assert!(caps.filters.group_policy_reads);
1808        assert!(caps.filters.catalog && caps.filters.managed_groups);
1809        assert!(!caps.filters.native && !caps.filters.preview);
1810        assert_eq!(caps.filters.evaluator_version, 0);
1811        assert!(caps.filters.codecs.is_empty());
1812        let absent = Capabilities::from_versions(false, OpVersions::new(1, 1, 1, 1));
1813        assert!(!absent.filters.group_policy_reads && !absent.filters.catalog);
1814    }
1815
1816    #[test]
1817    fn given_destination_versions_when_building_capabilities_then_the_core_surface_is_available() {
1818        use crate::codes::CHECKPOINT_OP_VERSION;
1819        use crate::hello::feature;
1820
1821        let versions = OpVersions::new(2, 2, 1, 1)
1822            .with_checkpoint(CHECKPOINT_OP_VERSION)
1823            .with_features(feature::DESTINATIONS);
1824        let caps = Capabilities::from_versions(true, versions);
1825
1826        assert!(caps.destinations.available);
1827        assert!(caps.destinations.lifecycle);
1828        assert!(caps.destinations.checkpoint_status);
1829        assert!(caps.destinations.query_routes);
1830        assert!(!caps.destinations.table_schema);
1831    }
1832
1833    #[test]
1834    fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1835        use crate::destination::BackendResourceId;
1836        use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1837        let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1838            BackendDescriptor::new(
1839                BackendResourceId::from_u128(1),
1840                BackendMode::Operational,
1841                "Embedded",
1842                BackendImplementation {
1843                    kind: "embedded".to_owned(),
1844                    version: "1.0.0".to_owned(),
1845                },
1846                1,
1847                1,
1848            ),
1849            BackendDescriptor::new(
1850                BackendResourceId::from_u128(2),
1851                BackendMode::Lakehouse,
1852                "Warehouse",
1853                BackendImplementation {
1854                    kind: "columnar".to_owned(),
1855                    version: "1.0.0".to_owned(),
1856                },
1857                1,
1858                1,
1859            ),
1860        ]);
1861        let json = serde_json::to_string(&caps).expect("serializes");
1862        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1863        assert_eq!(back.backends.len(), 2);
1864        assert_eq!(
1865            back.backends[1].resource_id,
1866            BackendResourceId::from_u128(2)
1867        );
1868        assert_eq!(back.backends[1].implementation.kind, "columnar");
1869
1870        // No advertised backends is omitted on the wire, so a pre-backends
1871        // capabilities reply stays byte-identical.
1872        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1873        let json = serde_json::to_string(&plain).expect("json");
1874        assert!(!json.contains("backends"), "empty backends omitted: {json}");
1875    }
1876
1877    #[test]
1878    fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1879        let custom = WireTopology {
1880            ops_stream: "custom-ops".to_owned(),
1881            ..WireTopology::default()
1882        };
1883        let caps =
1884            Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1885        let json = serde_json::to_string(&caps).expect("serializes");
1886        let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1887        assert_eq!(back.topology, Some(custom));
1888
1889        // No advertised topology (the default) is omitted on the wire, so a
1890        // pre-topology capabilities reply stays byte-identical.
1891        let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1892        let json = serde_json::to_string(&plain).expect("json");
1893        assert!(
1894            !json.contains("topology"),
1895            "absent topology omitted: {json}"
1896        );
1897    }
1898
1899    #[test]
1900    fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1901        let body = ErrorBody::from(&QueryError::IndexNotFound("readings".to_owned()));
1902        assert_eq!(body.code, ResultCode::NotFound);
1903        assert_eq!(body.http_status(), 404);
1904        assert!(body.message.contains("readings"));
1905        // Round-trips as JSON, the form the HTTP surface serves it in.
1906        let json = serde_json::to_string(&body).expect("serializes");
1907        let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1908        assert_eq!(back, body);
1909    }
1910
1911    #[test]
1912    #[cfg(feature = "http-client")]
1913    fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1914        let query = KvScanQuery {
1915            prefix: Some("dXNlcjo".to_owned()),
1916            limit: Some(50),
1917            ..Default::default()
1918        };
1919        let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1920        assert_eq!(encoded, "prefix=dXNlcjo&limit=50");
1921        // Field names are the PARAM_* consts verbatim.
1922        assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1923        assert!(encoded.contains(&format!("{PARAM_LIMIT}=")));
1924    }
1925
1926    #[test]
1927    #[cfg(feature = "http-client")]
1928    fn given_names_with_reserved_characters_when_building_a_binding_path_then_should_encode_each_segment()
1929     {
1930        assert_eq!(
1931            filter_binding_path("orbit", "fleet/changes", "desk?x#1"),
1932            "/agdx/filter-bindings/orbit/fleet%2Fchanges/desk%3Fx%231"
1933        );
1934    }
1935
1936    #[cfg(feature = "http-client")]
1937    #[test]
1938    fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1939        let projections = ProjectionListQuery {
1940            name_contains: Some("reading".to_owned()),
1941            id_prefix: Some("reading.".to_owned()),
1942            ..Default::default()
1943        };
1944        let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1945        assert_eq!(encoded, "name_contains=reading&id_prefix=reading.");
1946        assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1947        assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1948
1949        let schemas = SchemaListQuery {
1950            name_contains: Some("Reading".to_owned()),
1951            stream: None,
1952        };
1953        assert_eq!(
1954            serde_urlencoded::to_string(&schemas).expect("encodes"),
1955            "name_contains=Reading"
1956        );
1957        let scoped = SchemaListQuery {
1958            name_contains: None,
1959            stream: Some("acme".to_owned()),
1960        };
1961        assert_eq!(
1962            serde_urlencoded::to_string(&scoped).expect("encodes"),
1963            format!("{PARAM_STREAM}=acme")
1964        );
1965        assert_eq!(
1966            serde_urlencoded::to_string(SchemaQuery::default()).expect("encodes"),
1967            ""
1968        );
1969        assert_eq!(
1970            serde_urlencoded::to_string(SchemaQuery {
1971                stream: Some("acme".to_owned()),
1972            })
1973            .expect("encodes"),
1974            format!("{PARAM_STREAM}=acme")
1975        );
1976    }
1977
1978    #[test]
1979    fn given_a_register_schema_body_without_a_stream_when_encoded_then_should_omit_it() {
1980        let body = RegisterSchemaBody {
1981            source: crate::control::SchemaSource::JsonSchema {
1982                schema: "{}".to_owned(),
1983            },
1984            name: None,
1985            version: None,
1986            stream: None,
1987        };
1988        let json = serde_json::to_string(&body).expect("encodes");
1989        assert!(
1990            !json.contains("stream"),
1991            "an unset stream is omitted: {json}"
1992        );
1993        let scoped: RegisterSchemaBody = serde_json::from_str(
1994            r#"{"source":{"kind":"json_schema","schema":"{}"},"stream":"acme"}"#,
1995        )
1996        .expect("decodes");
1997        assert_eq!(scoped.stream.as_deref(), Some("acme"));
1998    }
1999}