Skip to main content

brain_db_sdk/
client.rs

1//! The high-level client: connect, handshake, and serve the v1 + typed-graph
2//! verbs over a multiplexed connection.
3//!
4//! [`BrainClient::connect`] opens a TCP connection, runs the handshake, and
5//! returns a client bound to the session the server granted. The client sits on
6//! a [`MuxConnection`], so every verb takes `&self` and many requests run in
7//! flight at once over the one connection — share a `BrainClient` across tasks
8//! (e.g. behind an `Arc`) and call freely. To add retry, wrap a verb call in
9//! [`crate::retry::with_retry`]; the stable `request_id` each builder mints
10//! makes the resend idempotent server-side.
11
12use std::net::SocketAddr;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use tokio::net::TcpStream;
16
17use crate::error::{BrainError, Result};
18use crate::mux::HandshakeOutcome;
19use crate::mux::{MuxConnection, Subscription};
20use crate::wire::cbor::{f32_slice_to_le_bytes, from_cbor_bytes, to_cbor_bytes};
21use crate::wire::opcode::Opcode;
22use crate::wire::types::{
23    AnswerKindWire, AuthCredentials, AuthMethod, AuthPayload, CancelStreamAck, CancelStreamRequest,
24    CancellationReason, EncodeRequest, EncodeResponse, EncodeVectorDirectRequest,
25    EntityCreateRequest, EntityCreateResponse, EntityGetRequest, EntityGetResponse, EntityListItem,
26    EntityListRequest, EntityListResponseFrame, EntityMergeRequest, EntityMergeResponse,
27    EntityRenameRequest, EntityRenameResponse, EntityResolveRequest, EntityResolveResponse,
28    EntityTombstoneRequest, EntityTombstoneResponse, EntityUnmergeRequest, EntityUnmergeResponse,
29    EntityUpdateRequest, EntityUpdateResponse, ExtractorListRequest, ExtractorListResponseFrame,
30    ForgetRequest, ForgetResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, GraphEdge,
31    GraphFetchRequest, GraphFetchResponseFrame, GraphNode, HelloCapabilities, HelloPayload,
32    InferenceStep, LinkRequest, LinkResponse, MaterializeProceduralRequest,
33    MaterializeProceduralResponse, MemoryInspectRequest, MemoryInspectResponse, MemoryListItem,
34    MemoryListRequest, MemoryListResponseFrame, MemoryResult, MtlsClaim, PingRequest, PlanRequest,
35    PlanResponseFrame, PlanStep, PongResponse, QueryExplainRequest, QueryExplainResponse,
36    QueryTraceRequest, QueryTraceResponse, ReasonRequest, ReasonResponseFrame, RecallRequest,
37    RecallResponseFrame, RelationCreateRequest, RelationCreateResponse, RelationGetRequest,
38    RelationGetResponse, RelationListFromRequest, RelationListFromResponseFrame,
39    RelationListToRequest, RelationListToResponseFrame, RelationSupersedeRequest,
40    RelationSupersedeResponse, RelationTombstoneRequest, RelationTombstoneResponse,
41    RelationTraverseRequest, RelationTraverseResponseFrame, RelationView, SchemaDropRequest,
42    SchemaDropResponse, SchemaGetRequest, SchemaGetResponse, SchemaListItemWire, SchemaListRequest,
43    SchemaListResponseFrame, SchemaReplaceRequest, SchemaReplaceResponse, SchemaUploadRequest,
44    SchemaUploadResponse, SchemaValidateRequest, SchemaValidateResponse, ServerFeatures,
45    SessionCreateRequest, SessionCreateResponse, SessionDeleteRequest, SessionDeleteResponse,
46    SessionListRequest, SessionListResponse, SpaceCreateRequest, SpaceCreateResponse,
47    SpaceDeleteRequest, SpaceDeleteResponse, SpaceListRequest, SpaceListResponse, SpacePermissions,
48    StatementCreateRequest, StatementCreateResponse, StatementGetRequest, StatementGetResponse,
49    StatementHistoryRequest, StatementHistoryResponseFrame, StatementListRequest,
50    StatementListResponseFrame, StatementRetractRequest, StatementRetractResponse,
51    StatementSupersedeRequest, StatementSupersedeResponse, StatementTombstoneRequest,
52    StatementTombstoneResponse, StatementView, SubscribeRequest, TraversalPathWire,
53    TxnAbortRequest, TxnAbortResponse, TxnBeginRequest, TxnBeginResponse, TxnCommitRequest,
54    TxnCommitResponse, UnlinkRequest, UnlinkResponse,
55};
56
57/// Default `client_id` advertised in HELLO.
58const DEFAULT_CLIENT_ID: &str = "brain-db-sdk-rust";
59
60/// How the client authenticates after WELCOME. Auth is mandatory: there is no
61/// anonymous mode. The credential is the connection's whole identity — the
62/// server resolves `(namespace, space, permissions)` from it and refuses any
63/// connection it cannot resolve.
64#[derive(Clone, Debug)]
65pub enum Auth {
66    /// A shared bearer token.
67    Token(Vec<u8>),
68    /// An mTLS subject claim.
69    Mtls(MtlsClaim),
70}
71
72impl Auth {
73    fn method(&self) -> AuthMethod {
74        match self {
75            Auth::Token(_) => AuthMethod::Token,
76            Auth::Mtls(_) => AuthMethod::Mtls,
77        }
78    }
79
80    fn credentials(self) -> AuthCredentials {
81        match self {
82            Auth::Token(t) => AuthCredentials::Token(t),
83            Auth::Mtls(c) => AuthCredentials::Mtls(c),
84        }
85    }
86}
87
88/// Connection configuration. Build one with [`ClientConfig::new`], which takes
89/// the mandatory credential and fills the rest with sensible defaults (wire
90/// version 1, streaming advertised). There is no `Default`: a connection with
91/// no credential cannot exist.
92#[derive(Clone, Debug)]
93pub struct ClientConfig {
94    /// Free-form client identifier sent in HELLO.
95    pub client_id: String,
96    /// Wire versions the client supports, in preference order. `u8` on the
97    /// wire.
98    pub supported_versions: Vec<u8>,
99    /// Capabilities advertised in HELLO.
100    pub capabilities: HelloCapabilities,
101    /// How to authenticate after WELCOME. Mandatory — the credential is the
102    /// connection's identity; the server assigns the space and namespace.
103    pub auth: Auth,
104    /// Deadline for the TCP connect. `None` waits indefinitely.
105    pub connect_timeout: Option<Duration>,
106    /// Per-response read deadline applied after connecting.
107    pub request_timeout: Option<Duration>,
108}
109
110impl ClientConfig {
111    /// A config for the given credential with default transport settings.
112    #[must_use]
113    pub fn new(auth: Auth) -> Self {
114        Self {
115            client_id: DEFAULT_CLIENT_ID.to_string(),
116            supported_versions: vec![1],
117            capabilities: HelloCapabilities {
118                streaming: true,
119                compression_zstd: false,
120                server_push: false,
121            },
122            auth,
123            connect_timeout: Some(Duration::from_secs(10)),
124            request_timeout: Some(Duration::from_secs(30)),
125        }
126    }
127}
128
129/// The connection the server granted at handshake time.
130#[derive(Clone, Debug)]
131pub struct ConnectionInfo {
132    /// Identity the server resolved from the credential. The client never
133    /// claims one; this is who the connection turned out to be.
134    pub space_id: [u8; 16],
135    /// Free-form server build identifier from WELCOME, for diagnostics.
136    pub server_id: String,
137    /// Wire version both sides settled on. Always `1` today; a value the
138    /// client did not offer is rejected as [`BrainError::VersionMismatch`].
139    pub chosen_version: u8,
140    /// Per-connection identifier the server minted at WELCOME.
141    pub connection_id: [u8; 16],
142    /// Shard this connection's space lives on. Routing detail, surfaced for
143    /// diagnostics; a client never chooses it.
144    pub bound_shard_id: u16,
145    /// What this credential is allowed to do. Checked server-side on every
146    /// request — reading it here only lets a caller fail early.
147    pub permissions: SpacePermissions,
148    /// Owning tenant the server bound this connection to (server-derived from
149    /// auth). Empty when the connection resolves to the reserved `brain`
150    /// system namespace. Read-only — the client never sends a namespace.
151    pub namespace: String,
152    /// Negotiated limits from WELCOME (max payload, max concurrent streams,
153    /// idle timeout).
154    pub server_features: ServerFeatures,
155}
156
157/// The drained result of a RECALL. Brain recall is not always top-k: it
158/// answers "remember this" the way a real memory does. `answer_kind` reports
159/// what shape the answer took (a single memory, many memories, or none);
160/// `memories` carries the recalled memory hits.
161#[derive(Clone, Debug, PartialEq)]
162pub struct RecallAnswer {
163    /// What shape the answer took: one memory, many, or none.
164    pub answer_kind: AnswerKindWire,
165    /// The recalled memories, in the order the server streamed them.
166    pub memories: Vec<MemoryResult>,
167}
168
169impl RecallAnswer {
170    /// True when recall produced no memories.
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.memories.is_empty()
174    }
175
176    /// The recalled memory hits.
177    #[must_use]
178    pub fn memories(&self) -> &[MemoryResult] {
179        &self.memories
180    }
181}
182
183/// A connected, handshaken Brain client over a multiplexed connection. Verbs
184/// take `&self` and are safe to call concurrently from many tasks.
185pub struct BrainClient {
186    conn: MuxConnection<TcpStream>,
187    connection: ConnectionInfo,
188}
189
190impl std::fmt::Debug for BrainClient {
191    /// Prints the session, never the socket or the credential.
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("BrainClient")
194            .field("space_id", &hex16(&self.connection.space_id))
195            .field("namespace", &self.connection.namespace)
196            .field("chosen_version", &self.connection.chosen_version)
197            .finish_non_exhaustive()
198    }
199}
200
201/// Lowercase hex of a 16-byte id, for `Debug` output.
202fn hex16(id: &[u8; 16]) -> String {
203    id.iter().fold(String::with_capacity(32), |mut s, b| {
204        use std::fmt::Write as _;
205        let _ = write!(s, "{b:02x}");
206        s
207    })
208}
209
210impl BrainClient {
211    /// Connect to `addr` with the given credential and default transport
212    /// settings, then run the handshake. The credential is mandatory — the
213    /// server resolves the connection's identity from it.
214    pub async fn connect(addr: SocketAddr, auth: Auth) -> Result<Self> {
215        Self::connect_with(addr, ClientConfig::new(auth)).await
216    }
217
218    /// Connect to `addr` with an explicit configuration and run the handshake.
219    pub async fn connect_with(addr: SocketAddr, config: ClientConfig) -> Result<Self> {
220        let stream = match config.connect_timeout {
221            Some(timeout) => tokio::time::timeout(timeout, TcpStream::connect(addr))
222                .await
223                .map_err(|_| BrainError::Timeout(timeout))??,
224            None => TcpStream::connect(addr).await?,
225        };
226        // Small request/response frames benefit from disabling Nagle; the
227        // transport already writes each frame in one call.
228        stream.set_nodelay(true)?;
229
230        Self::handshake_over(stream, config).await
231    }
232
233    /// Run the handshake over an already-connected stream, returning the live
234    /// multiplexed client. Factored out so the path is exercised by in-process
235    /// tests over a loopback socket.
236    async fn handshake_over(stream: TcpStream, config: ClientConfig) -> Result<Self> {
237        let hello = HelloPayload {
238            client_id: config.client_id,
239            supported_versions: config.supported_versions,
240            capabilities: config.capabilities,
241            client_connection_token: None,
242        };
243        let auth = AuthPayload {
244            method: config.auth.method(),
245            credentials: config.auth.credentials(),
246        };
247
248        let (conn, HandshakeOutcome { welcome, auth_ok }) =
249            MuxConnection::handshake(stream, hello, auth, config.request_timeout).await?;
250
251        let connection = ConnectionInfo {
252            space_id: auth_ok.space_id,
253            server_id: welcome.server_id,
254            chosen_version: welcome.chosen_version,
255            connection_id: welcome.connection_id,
256            bound_shard_id: auth_ok.bound_shard_id,
257            permissions: auth_ok.permissions,
258            namespace: auth_ok.namespace,
259            server_features: welcome.server_features,
260        };
261        Ok(Self { conn, connection })
262    }
263
264    /// Whether this connection is dead (the server closed it, e.g. on
265    /// restart, or the socket failed). Requests on it fail at once; reconnect.
266    #[must_use]
267    pub fn is_closed(&self) -> bool {
268        self.conn.is_closed()
269    }
270
271    /// The negotiated connection.
272    #[must_use]
273    pub fn connection(&self) -> &ConnectionInfo {
274        &self.connection
275    }
276
277    /// The space id this connection acts as.
278    #[must_use]
279    pub fn space_id(&self) -> [u8; 16] {
280        self.connection.space_id
281    }
282
283    /// The owning tenant the server bound this connection to (server-derived
284    /// from auth). Empty when the connection resolves to the reserved `brain`
285    /// system namespace. Read-only — the client never sends a namespace.
286    #[must_use]
287    pub fn namespace(&self) -> &str {
288        &self.connection.namespace
289    }
290
291    /// Store a memory from text (ENCODE).
292    pub async fn encode(&self, request: &EncodeRequest) -> Result<EncodeResponse> {
293        self.unary(
294            Opcode::EncodeReq,
295            Opcode::EncodeResp,
296            "ENCODE_RESP",
297            request,
298        )
299        .await
300    }
301
302    /// Retrieve a memory by cue. RECALL streams one or more `RECALL_RESP`
303    /// frames terminated by EOS; this drains them into one [`RecallAnswer`].
304    /// `answer_kind` is the final frame's verdict; `memories` is concatenated
305    /// across every frame in order. Recall is not always top-k: the answer may
306    /// be a single memory, many memories, or none. For the raw streamed frames
307    /// (cumulative counts, `estimated_remaining`), use [`Self::recall_frames`].
308    pub async fn recall(&self, request: &RecallRequest) -> Result<RecallAnswer> {
309        let frames = self.recall_frames(request).await?;
310        let mut memories = Vec::new();
311        // The answer_kind on the final (EOS) frame is the authoritative
312        // verdict; default to None if the server sent nothing.
313        let mut answer_kind = AnswerKindWire::None;
314        for frame in frames {
315            answer_kind = frame.answer_kind;
316            memories.extend(frame.memories);
317        }
318        Ok(RecallAnswer {
319            answer_kind,
320            memories,
321        })
322    }
323
324    /// Retrieve memories by cue, returning each decoded `RECALL_RESP` frame as
325    /// streamed (preserving `is_final`, `cumulative_count`,
326    /// `estimated_remaining`). The last frame carries the EOS flag.
327    pub async fn recall_frames(&self, request: &RecallRequest) -> Result<Vec<RecallResponseFrame>> {
328        let frames = self
329            .conn
330            .request(Opcode::RecallReq, to_cbor_bytes(request))
331            .await?;
332        let mut out = Vec::with_capacity(frames.len());
333        for frame in frames {
334            if frame.opcode != Opcode::RecallResp as u16 {
335                return Err(BrainError::Protocol(format!(
336                    "expected RECALL_RESP ({:#06x}), got {:#06x}",
337                    Opcode::RecallResp as u16,
338                    frame.opcode
339                )));
340            }
341            out.push(from_cbor_bytes(&frame.payload)?);
342        }
343        Ok(out)
344    }
345
346    /// Forget a memory (soft tombstone or hard zeroing, per the request).
347    pub async fn forget(&self, request: &ForgetRequest) -> Result<ForgetResponse> {
348        self.unary(
349            Opcode::ForgetReq,
350            Opcode::ForgetResp,
351            "FORGET_RESP",
352            request,
353        )
354        .await
355    }
356
357    /// Create or reweight an edge between two memories (LINK). Returns
358    /// whether the edge already existed (LINK overwrote its weight).
359    pub async fn link(&self, request: &LinkRequest) -> Result<LinkResponse> {
360        self.unary(Opcode::LinkReq, Opcode::LinkResp, "LINK_RESP", request)
361            .await
362    }
363
364    /// Remove an edge identified by `(source, kind, target)` (UNLINK).
365    /// Idempotent: removing a non-existent edge succeeds with
366    /// `removed = false`.
367    pub async fn unlink(&self, request: &UnlinkRequest) -> Result<UnlinkResponse> {
368        self.unary(
369            Opcode::UnlinkReq,
370            Opcode::UnlinkResp,
371            "UNLINK_RESP",
372            request,
373        )
374        .await
375    }
376
377    /// Plan a path from `start` to `goal` (PLAN), flattening every streamed
378    /// frame's `steps` into one ordered list. For the raw streamed frames
379    /// (`is_final`, terminal `plan_status`), use [`Self::plan_frames`].
380    pub async fn plan(&self, request: &PlanRequest) -> Result<Vec<PlanStep>> {
381        let frames = self.plan_frames(request).await?;
382        let mut steps = Vec::new();
383        for frame in frames {
384            steps.extend(frame.steps);
385        }
386        Ok(steps)
387    }
388
389    /// Plan a path, returning each decoded PLAN_RESP frame as streamed
390    /// (preserving `is_final` and the terminal `plan_status`).
391    pub async fn plan_frames(&self, request: &PlanRequest) -> Result<Vec<PlanResponseFrame>> {
392        self.streamed(Opcode::PlanReq, Opcode::PlanResp, "PLAN_RESP", request)
393            .await
394    }
395
396    /// Reason about an observation (REASON), flattening every streamed
397    /// frame's `inferences` into one ordered list. For the raw streamed
398    /// frames (`is_final`, terminal `reason_status`), use
399    /// [`Self::reason_frames`].
400    pub async fn reason(&self, request: &ReasonRequest) -> Result<Vec<InferenceStep>> {
401        let frames = self.reason_frames(request).await?;
402        let mut inferences = Vec::new();
403        for frame in frames {
404            inferences.extend(frame.inferences);
405        }
406        Ok(inferences)
407    }
408
409    /// Reason about an observation, returning each decoded REASON_RESP frame
410    /// as streamed (preserving `is_final` and the terminal `reason_status`).
411    pub async fn reason_frames(&self, request: &ReasonRequest) -> Result<Vec<ReasonResponseFrame>> {
412        self.streamed(
413            Opcode::ReasonReq,
414            Opcode::ReasonResp,
415            "REASON_RESP",
416            request,
417        )
418        .await
419    }
420
421    /// Create a typed entity (ENTITY_CREATE).
422    pub async fn create_entity(
423        &self,
424        request: &EntityCreateRequest,
425    ) -> Result<EntityCreateResponse> {
426        self.unary(
427            Opcode::EntityCreateReq,
428            Opcode::EntityCreateResp,
429            "ENTITY_CREATE_RESP",
430            request,
431        )
432        .await
433    }
434
435    /// Replace an entity's name, aliases, and attributes (ENTITY_UPDATE).
436    pub async fn update_entity(
437        &self,
438        request: &EntityUpdateRequest,
439    ) -> Result<EntityUpdateResponse> {
440        self.unary(
441            Opcode::EntityUpdateReq,
442            Opcode::EntityUpdateResp,
443            "ENTITY_UPDATE_RESP",
444            request,
445        )
446        .await
447    }
448
449    /// Rename an entity, optionally keeping the old name as an alias
450    /// (ENTITY_RENAME).
451    pub async fn rename_entity(
452        &self,
453        request: &EntityRenameRequest,
454    ) -> Result<EntityRenameResponse> {
455        self.unary(
456            Opcode::EntityRenameReq,
457            Opcode::EntityRenameResp,
458            "ENTITY_RENAME_RESP",
459            request,
460        )
461        .await
462    }
463
464    /// Merge two entities that are the same real-world thing (ENTITY_MERGE).
465    /// Reversible within the returned grace window via [`Self::unmerge_entity`].
466    pub async fn merge_entities(
467        &self,
468        request: &EntityMergeRequest,
469    ) -> Result<EntityMergeResponse> {
470        self.unary(
471            Opcode::EntityMergeReq,
472            Opcode::EntityMergeResp,
473            "ENTITY_MERGE_RESP",
474            request,
475        )
476        .await
477    }
478
479    /// Undo a merge within its grace window (ENTITY_UNMERGE).
480    pub async fn unmerge_entity(
481        &self,
482        request: &EntityUnmergeRequest,
483    ) -> Result<EntityUnmergeResponse> {
484        self.unary(
485            Opcode::EntityUnmergeReq,
486            Opcode::EntityUnmergeResp,
487            "ENTITY_UNMERGE_RESP",
488            request,
489        )
490        .await
491    }
492
493    /// Retire an entity with an audit reason (ENTITY_TOMBSTONE). Soft: the row
494    /// survives but drops out of resolution and traversal.
495    pub async fn tombstone_entity(
496        &self,
497        request: &EntityTombstoneRequest,
498    ) -> Result<EntityTombstoneResponse> {
499        self.unary(
500            Opcode::EntityTombstoneReq,
501            Opcode::EntityTombstoneResp,
502            "ENTITY_TOMBSTONE_RESP",
503            request,
504        )
505        .await
506    }
507
508    /// Create a statement (STATEMENT_CREATE). The response reports any
509    /// auto-superseded prior statement and the supersession chain root.
510    pub async fn create_statement(
511        &self,
512        request: &StatementCreateRequest,
513    ) -> Result<StatementCreateResponse> {
514        self.unary(
515            Opcode::StatementCreateReq,
516            Opcode::StatementCreateResp,
517            "STATEMENT_CREATE_RESP",
518            request,
519        )
520        .await
521    }
522
523    /// Create a relation between two entities (RELATION_CREATE).
524    pub async fn create_relation(
525        &self,
526        request: &RelationCreateRequest,
527    ) -> Result<RelationCreateResponse> {
528        self.unary(
529            Opcode::RelationCreateReq,
530            Opcode::RelationCreateResp,
531            "RELATION_CREATE_RESP",
532            request,
533        )
534        .await
535    }
536
537    /// Upload a schema document (SCHEMA_UPLOAD). With `dry_run` set the server
538    /// validates without applying; the response carries any validation errors
539    /// and a backward-compatibility verdict.
540    pub async fn upload_schema(
541        &self,
542        request: &SchemaUploadRequest,
543    ) -> Result<SchemaUploadResponse> {
544        self.unary(
545            Opcode::SchemaUploadReq,
546            Opcode::SchemaUploadResp,
547            "SCHEMA_UPLOAD_RESP",
548            request,
549        )
550        .await
551    }
552
553    /// Fetch one relation by id (RELATION_GET). `follow_supersession` returns
554    /// the current chain head when the id has been superseded.
555    pub async fn get_relation(&self, request: &RelationGetRequest) -> Result<RelationGetResponse> {
556        self.unary(
557            Opcode::RelationGetReq,
558            Opcode::RelationGetResp,
559            "RELATION_GET_RESP",
560            request,
561        )
562        .await
563    }
564
565    /// Revise a relation, keeping both on one chain (RELATION_SUPERSEDE).
566    pub async fn supersede_relation(
567        &self,
568        request: &RelationSupersedeRequest,
569    ) -> Result<RelationSupersedeResponse> {
570        self.unary(
571            Opcode::RelationSupersedeReq,
572            Opcode::RelationSupersedeResp,
573            "RELATION_SUPERSEDE_RESP",
574            request,
575        )
576        .await
577    }
578
579    /// Soft-retire a relation with a reason (RELATION_TOMBSTONE).
580    pub async fn tombstone_relation(
581        &self,
582        request: &RelationTombstoneRequest,
583    ) -> Result<RelationTombstoneResponse> {
584        self.unary(
585            Opcode::RelationTombstoneReq,
586            Opcode::RelationTombstoneResp,
587            "RELATION_TOMBSTONE_RESP",
588            request,
589        )
590        .await
591    }
592
593    /// Multi-hop walk of the relation graph from an entity (RELATION_TRAVERSE),
594    /// flattening every streamed frame's `paths`. For the raw frames (with
595    /// `truncated` / `total_paths`), use [`Self::traverse_relations_frames`].
596    pub async fn traverse_relations(
597        &self,
598        request: &RelationTraverseRequest,
599    ) -> Result<Vec<TraversalPathWire>> {
600        let frames = self.traverse_relations_frames(request).await?;
601        let mut paths = Vec::new();
602        for frame in frames {
603            paths.extend(frame.paths);
604        }
605        Ok(paths)
606    }
607
608    /// Traverse the relation graph, returning each decoded RELATION_TRAVERSE
609    /// frame.
610    pub async fn traverse_relations_frames(
611        &self,
612        request: &RelationTraverseRequest,
613    ) -> Result<Vec<RelationTraverseResponseFrame>> {
614        self.streamed(
615            Opcode::RelationTraverseReq,
616            Opcode::RelationTraverseResp,
617            "RELATION_TRAVERSE_RESP",
618            request,
619        )
620        .await
621    }
622
623    /// Return a query's plan without running it (QUERY_EXPLAIN).
624    pub async fn query_explain(
625        &self,
626        request: &QueryExplainRequest,
627    ) -> Result<QueryExplainResponse> {
628        self.unary(
629            Opcode::QueryExplainReq,
630            Opcode::QueryExplainResp,
631            "QUERY_EXPLAIN_RESP",
632            request,
633        )
634        .await
635    }
636
637    /// Run a query and return its per-stage execution trace (QUERY_TRACE).
638    pub async fn query_trace(&self, request: &QueryTraceRequest) -> Result<QueryTraceResponse> {
639        self.unary(
640            Opcode::QueryTraceReq,
641            Opcode::QueryTraceResp,
642            "QUERY_TRACE_RESP",
643            request,
644        )
645        .await
646    }
647
648    /// Write a pre-computed embedding directly (ENCODE_VECTOR_DIRECT), bypassing
649    /// the server's owned embedding. The vector rides the trailing raw f32
650    /// section of the frame, not the CBOR map.
651    pub async fn encode_vector_direct(
652        &self,
653        request: &EncodeVectorDirectRequest,
654    ) -> Result<EncodeResponse> {
655        let mut payload = to_cbor_bytes(request);
656        payload.extend_from_slice(&f32_slice_to_le_bytes(&request.vector));
657        let frame = self
658            .conn
659            .request_one(Opcode::EncodeVectorDirectReq, payload)
660            .await?;
661        if frame.opcode != Opcode::EncodeVectorDirectResp as u16 {
662            return Err(BrainError::Protocol(format!(
663                "expected ENCODE_VECTOR_DIRECT_RESP ({:#06x}), got {:#06x}",
664                Opcode::EncodeVectorDirectResp as u16,
665                frame.opcode
666            )));
667        }
668        Ok(from_cbor_bytes(&frame.payload)?)
669    }
670
671    /// Materialize a procedural-memory system block (MATERIALIZE_PROCEDURAL).
672    pub async fn materialize_procedural(
673        &self,
674        request: &MaterializeProceduralRequest,
675    ) -> Result<MaterializeProceduralResponse> {
676        self.unary(
677            Opcode::MaterializeProceduralReq,
678            Opcode::MaterializeProceduralResp,
679            "MATERIALIZE_PROCEDURAL_RESP",
680            request,
681        )
682        .await
683    }
684
685    /// Introspect the connected shard's live capabilities (GET_CAPABILITIES):
686    /// whether the reranker is loaded, which extractor tiers are enabled, the
687    /// active user schema namespaces, and the embedding dimensionality.
688    pub async fn capabilities(
689        &self,
690        request: &GetCapabilitiesRequest,
691    ) -> Result<GetCapabilitiesResponse> {
692        self.unary(
693            Opcode::GetCapabilitiesReq,
694            Opcode::GetCapabilitiesResp,
695            "GET_CAPABILITIES_RESP",
696            request,
697        )
698        .await
699    }
700
701    /// List the always-on extractors registered on the connected shard
702    /// (EXTRACTOR_LIST): id, namespace, name, kind, schema version, and
703    /// creation time. Read-only introspection — extraction is always-on and
704    /// cannot be toggled.
705    pub async fn extractor_list(
706        &self,
707        request: &ExtractorListRequest,
708    ) -> Result<ExtractorListResponseFrame> {
709        self.unary(
710            Opcode::ExtractorListReq,
711            Opcode::ExtractorListResp,
712            "EXTRACTOR_LIST_RESP",
713            request,
714        )
715        .await
716    }
717
718    /// Fetch one entity by id (ENTITY_GET).
719    pub async fn get_entity(&self, request: &EntityGetRequest) -> Result<EntityGetResponse> {
720        self.unary(
721            Opcode::EntityGetReq,
722            Opcode::EntityGetResp,
723            "ENTITY_GET_RESP",
724            request,
725        )
726        .await
727    }
728
729    /// Inspect one memory's durable write-artifact bundle (MEMORY_INSPECT):
730    /// its text plus the per-stage record of what the write built — the
731    /// embedding vector, the stored record, the analyzed keyword terms, the
732    /// write-time HyPE questions, and the extracted knowledge graph. Returns
733    /// `found = false` (with an empty `artifact`) for an id that doesn't exist
734    /// under the caller's scope.
735    pub async fn memory_inspect(
736        &self,
737        request: &MemoryInspectRequest,
738    ) -> Result<MemoryInspectResponse> {
739        self.unary(
740            Opcode::MemoryInspectReq,
741            Opcode::MemoryInspectResp,
742            "MEMORY_INSPECT_RESP",
743            request,
744        )
745        .await
746    }
747
748    /// Resolve a candidate name to an entity (ENTITY_RESOLVE). The server
749    /// currently requires `entity_type_hint != 0` and resolves by exact
750    /// canonical name; `allow_create` lets it mint a new entity on a miss.
751    pub async fn resolve_entity(
752        &self,
753        request: &EntityResolveRequest,
754    ) -> Result<EntityResolveResponse> {
755        self.unary(
756            Opcode::EntityResolveReq,
757            Opcode::EntityResolveResp,
758            "ENTITY_RESOLVE_RESP",
759            request,
760        )
761        .await
762    }
763
764    /// Fetch one statement by id (STATEMENT_GET). With `follow_supersession`
765    /// set, the server may redirect to the current entry in the chain.
766    pub async fn get_statement(
767        &self,
768        request: &StatementGetRequest,
769    ) -> Result<StatementGetResponse> {
770        self.unary(
771            Opcode::StatementGetReq,
772            Opcode::StatementGetResp,
773            "STATEMENT_GET_RESP",
774            request,
775        )
776        .await
777    }
778
779    /// Fetch one schema version (SCHEMA_GET). `version == 0` selects the active
780    /// version.
781    pub async fn get_schema(&self, request: &SchemaGetRequest) -> Result<SchemaGetResponse> {
782        self.unary(
783            Opcode::SchemaGetReq,
784            Opcode::SchemaGetResp,
785            "SCHEMA_GET_RESP",
786            request,
787        )
788        .await
789    }
790
791    /// Validate a schema document without persisting it (SCHEMA_VALIDATE).
792    pub async fn validate_schema(
793        &self,
794        request: &SchemaValidateRequest,
795    ) -> Result<SchemaValidateResponse> {
796        self.unary(
797            Opcode::SchemaValidateReq,
798            Opcode::SchemaValidateResp,
799            "SCHEMA_VALIDATE_RESP",
800            request,
801        )
802        .await
803    }
804
805    /// Replace a namespace's schema wholesale (SCHEMA_REPLACE).
806    ///
807    /// DESTRUCTIVE. Every declared row in the namespace is dropped before the
808    /// new document lands; entities whose type disappears survive as orphans,
809    /// readable as plain memories but no longer enriched from the typed-graph
810    /// tables. Use [`Self::upload_schema`] for the additive, versioned path —
811    /// this is the one you reach for when the shape itself is wrong.
812    ///
813    /// `force_drop_existing` MUST be `true`; the server rejects `false` with
814    /// `InvalidRequest`. The SDK does not default it, so the destructive
815    /// intent is written at the call site.
816    pub async fn replace_schema(
817        &self,
818        request: &SchemaReplaceRequest,
819    ) -> Result<SchemaReplaceResponse> {
820        self.unary(
821            Opcode::SchemaReplaceReq,
822            Opcode::SchemaReplaceResp,
823            "SCHEMA_REPLACE_RESP",
824            request,
825        )
826        .await
827    }
828
829    /// Drop a single declared predicate or relation_type (SCHEMA_DROP).
830    ///
831    /// The surgical counterpart to [`Self::replace_schema`]: it narrows the
832    /// active schema by one type rather than swapping the whole namespace.
833    /// Existing rows on the dropped type survive as orphans, readable as plain
834    /// memories but no longer enriched from the typed-graph tables.
835    ///
836    /// `force` is required only when the target still has live rows; the server
837    /// rejects `false` in that case with `Conflict` and mutates nothing. The
838    /// SDK does not default it, so the destructive intent is written at the
839    /// call site.
840    pub async fn drop_schema(&self, request: &SchemaDropRequest) -> Result<SchemaDropResponse> {
841        self.unary(
842            Opcode::SchemaDropReq,
843            Opcode::SchemaDropResp,
844            "SCHEMA_DROP_RESP",
845            request,
846        )
847        .await
848    }
849
850    /// Client-initiated liveness probe (PING → PONG).
851    ///
852    /// Distinct from the server's idle-timer SERVER_PING keepalive, which the
853    /// mux auto-answers with CLIENT_PONG: this is an on-demand round-trip whose
854    /// PONG routes back by stream id through the normal unary path.
855    /// `client_timestamp_unix_nanos` defaults to the current wall clock when
856    /// `None`; the PONG echoes it back alongside the server's timestamp, so the
857    /// caller can measure RTT.
858    pub async fn ping(&self, client_timestamp_unix_nanos: Option<u64>) -> Result<PongResponse> {
859        let ts = client_timestamp_unix_nanos.unwrap_or_else(|| {
860            // Saturating rather than `as`: nanos since the epoch is a u128 and
861            // the cast would silently wrap. A clock before the epoch, or past
862            // u64 nanos (year 2554), yields a clamped stamp instead of a
863            // nonsense one — this is an RTT marker the server echoes back, so
864            // a clamped value is harmless and a wrapped one is misleading.
865            SystemTime::now()
866                .duration_since(UNIX_EPOCH)
867                .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
868        });
869        let request = PingRequest {
870            client_timestamp_unix_nanos: ts,
871        };
872        self.unary(Opcode::Ping, Opcode::Pong, "PONG", &request)
873            .await
874    }
875
876    /// Cancel an in-flight stream (CANCEL_STREAM).
877    ///
878    /// `target_stream_id` is the id of the stream to stop — take it from the
879    /// frames a streaming call is yielding (`*_frames` methods expose
880    /// `stream_id`). The request travels on its OWN stream id, so it does not
881    /// queue behind the frames it is cancelling; the server replies
882    /// `CANCEL_STREAM_ACK` and stops emitting for the target.
883    ///
884    /// This is why it cannot use the `unary` helper's request/response pairing
885    /// on a single stream: the acknowledgement and the cancelled stream are
886    /// deliberately different streams.
887    ///
888    /// Cancelling a stream that already finished is not an error — the server
889    /// acknowledges it regardless, so a racing consumer does not have to
890    /// coordinate with the reader loop.
891    pub async fn cancel_stream(
892        &self,
893        target_stream_id: u32,
894        reason: CancellationReason,
895    ) -> Result<CancelStreamAck> {
896        let request = CancelStreamRequest {
897            target_stream_id,
898            reason,
899        };
900        self.unary(
901            Opcode::CancelStream,
902            Opcode::CancelStreamAck,
903            "CANCEL_STREAM_ACK",
904            &request,
905        )
906        .await
907    }
908
909    /// List entities (ENTITY_LIST), flattening every streamed frame's `items`
910    /// into one ordered list. For the raw streamed frames (cursors, cumulative
911    /// counts, `is_final`), use [`Self::list_entities_frames`].
912    pub async fn list_entities(&self, request: &EntityListRequest) -> Result<Vec<EntityListItem>> {
913        let frames = self.list_entities_frames(request).await?;
914        let mut items = Vec::new();
915        for frame in frames {
916            items.extend(frame.items);
917        }
918        Ok(items)
919    }
920
921    /// List entities, returning each decoded ENTITY_LIST_RESP frame as streamed
922    /// (preserving `next_cursor`, `cumulative_count`, `is_final`).
923    pub async fn list_entities_frames(
924        &self,
925        request: &EntityListRequest,
926    ) -> Result<Vec<EntityListResponseFrame>> {
927        self.streamed(
928            Opcode::EntityListReq,
929            Opcode::EntityListResp,
930            "ENTITY_LIST_RESP",
931            request,
932        )
933        .await
934    }
935
936    /// List memories (MEMORY_LIST), flattening every streamed frame's `items`
937    /// into one ordered list. This is a pure paginated enumeration of the
938    /// caller's `(namespace, space)` memories — no query, no ranking. For the
939    /// raw streamed frames (cursors, cumulative counts, `is_final`), use
940    /// [`Self::memory_list_frames`].
941    pub async fn memory_list(&self, request: &MemoryListRequest) -> Result<Vec<MemoryListItem>> {
942        let frames = self.memory_list_frames(request).await?;
943        let mut items = Vec::new();
944        for frame in frames {
945            items.extend(frame.items);
946        }
947        Ok(items)
948    }
949
950    /// List memories, returning each decoded MEMORY_LIST_RESP frame as streamed
951    /// (preserving `next_cursor`, `cumulative_count`, `is_final`).
952    pub async fn memory_list_frames(
953        &self,
954        request: &MemoryListRequest,
955    ) -> Result<Vec<MemoryListResponseFrame>> {
956        self.streamed(
957            Opcode::MemoryListReq,
958            Opcode::MemoryListResp,
959            "MEMORY_LIST_RESP",
960            request,
961        )
962        .await
963    }
964
965    /// Export the caller's typed graph (GRAPH_FETCH), flattening every streamed
966    /// frame's nodes + edges into two ordered lists. Nodes/edges may repeat
967    /// across pages (completeness, not disjointness) — dedup by id if needed.
968    /// For the raw streamed frames (cursors, `is_final`), use
969    /// [`Self::graph_fetch_frames`].
970    pub async fn graph_fetch(
971        &self,
972        request: &GraphFetchRequest,
973    ) -> Result<(Vec<GraphNode>, Vec<GraphEdge>)> {
974        let frames = self.graph_fetch_frames(request).await?;
975        let mut nodes = Vec::new();
976        let mut edges = Vec::new();
977        for frame in frames {
978            nodes.extend(frame.nodes);
979            edges.extend(frame.edges);
980        }
981        Ok((nodes, edges))
982    }
983
984    /// Export the typed graph, returning each decoded GRAPH_FETCH_RESP frame as
985    /// streamed (preserving `next_cursor`, `is_final`).
986    pub async fn graph_fetch_frames(
987        &self,
988        request: &GraphFetchRequest,
989    ) -> Result<Vec<GraphFetchResponseFrame>> {
990        self.streamed(
991            Opcode::GraphFetchReq,
992            Opcode::GraphFetchResp,
993            "GRAPH_FETCH_RESP",
994            request,
995        )
996        .await
997    }
998
999    /// List statements (STATEMENT_LIST), flattening every streamed frame's
1000    /// `items`. For the raw frames, use [`Self::list_statements_frames`].
1001    pub async fn list_statements(
1002        &self,
1003        request: &StatementListRequest,
1004    ) -> Result<Vec<StatementView>> {
1005        let frames = self.list_statements_frames(request).await?;
1006        let mut items = Vec::new();
1007        for frame in frames {
1008            items.extend(frame.items);
1009        }
1010        Ok(items)
1011    }
1012
1013    /// List statements, returning each decoded STATEMENT_LIST_RESP frame.
1014    pub async fn list_statements_frames(
1015        &self,
1016        request: &StatementListRequest,
1017    ) -> Result<Vec<StatementListResponseFrame>> {
1018        self.streamed(
1019            Opcode::StatementListReq,
1020            Opcode::StatementListResp,
1021            "STATEMENT_LIST_RESP",
1022            request,
1023        )
1024        .await
1025    }
1026
1027    /// Replace a statement with a revised one (STATEMENT_SUPERSEDE), keeping
1028    /// both on the same chain so history is preserved.
1029    pub async fn supersede_statement(
1030        &self,
1031        request: &StatementSupersedeRequest,
1032    ) -> Result<StatementSupersedeResponse> {
1033        self.unary(
1034            Opcode::StatementSupersedeReq,
1035            Opcode::StatementSupersedeResp,
1036            "STATEMENT_SUPERSEDE_RESP",
1037            request,
1038        )
1039        .await
1040    }
1041
1042    /// Retire a statement with a coded reason (STATEMENT_TOMBSTONE). Soft and
1043    /// recoverable.
1044    pub async fn tombstone_statement(
1045        &self,
1046        request: &StatementTombstoneRequest,
1047    ) -> Result<StatementTombstoneResponse> {
1048        self.unary(
1049            Opcode::StatementTombstoneReq,
1050            Opcode::StatementTombstoneResp,
1051            "STATEMENT_TOMBSTONE_RESP",
1052            request,
1053        )
1054        .await
1055    }
1056
1057    /// Assert a statement was wrong (STATEMENT_RETRACT). Unlike a tombstone,
1058    /// retraction schedules a hard-zero so a genuine mistake gets scrubbed.
1059    pub async fn retract_statement(
1060        &self,
1061        request: &StatementRetractRequest,
1062    ) -> Result<StatementRetractResponse> {
1063        self.unary(
1064            Opcode::StatementRetractReq,
1065            Opcode::StatementRetractResp,
1066            "STATEMENT_RETRACT_RESP",
1067            request,
1068        )
1069        .await
1070    }
1071
1072    /// Walk a claim's full version chain (STATEMENT_HISTORY), flattening every
1073    /// streamed frame's `items`. For the raw frames, use
1074    /// [`Self::statement_history_frames`].
1075    pub async fn statement_history(
1076        &self,
1077        request: &StatementHistoryRequest,
1078    ) -> Result<Vec<StatementView>> {
1079        let frames = self.statement_history_frames(request).await?;
1080        let mut items = Vec::new();
1081        for frame in frames {
1082            items.extend(frame.items);
1083        }
1084        Ok(items)
1085    }
1086
1087    /// Walk a claim's version chain, returning each decoded STATEMENT_HISTORY
1088    /// frame (with `total_versions` / `is_final`).
1089    pub async fn statement_history_frames(
1090        &self,
1091        request: &StatementHistoryRequest,
1092    ) -> Result<Vec<StatementHistoryResponseFrame>> {
1093        self.streamed(
1094            Opcode::StatementHistoryReq,
1095            Opcode::StatementHistoryResp,
1096            "STATEMENT_HISTORY_RESP",
1097            request,
1098        )
1099        .await
1100    }
1101
1102    /// List relations originating from an entity (RELATION_LIST_FROM),
1103    /// flattening every streamed frame's `items`. For the raw frames, use
1104    /// [`Self::list_relations_from_frames`].
1105    pub async fn list_relations_from(
1106        &self,
1107        request: &RelationListFromRequest,
1108    ) -> Result<Vec<RelationView>> {
1109        let frames = self.list_relations_from_frames(request).await?;
1110        let mut items = Vec::new();
1111        for frame in frames {
1112            items.extend(frame.items);
1113        }
1114        Ok(items)
1115    }
1116
1117    /// List relations from an entity, returning each decoded
1118    /// RELATION_LIST_FROM_RESP frame.
1119    pub async fn list_relations_from_frames(
1120        &self,
1121        request: &RelationListFromRequest,
1122    ) -> Result<Vec<RelationListFromResponseFrame>> {
1123        self.streamed(
1124            Opcode::RelationListFromReq,
1125            Opcode::RelationListFromResp,
1126            "RELATION_LIST_FROM_RESP",
1127            request,
1128        )
1129        .await
1130    }
1131
1132    /// List relations pointing to an entity (RELATION_LIST_TO), flattening every
1133    /// streamed frame's `items`. For the raw frames, use
1134    /// [`Self::list_relations_to_frames`].
1135    pub async fn list_relations_to(
1136        &self,
1137        request: &RelationListToRequest,
1138    ) -> Result<Vec<RelationView>> {
1139        let frames = self.list_relations_to_frames(request).await?;
1140        let mut items = Vec::new();
1141        for frame in frames {
1142            items.extend(frame.items);
1143        }
1144        Ok(items)
1145    }
1146
1147    /// List relations to an entity, returning each decoded
1148    /// RELATION_LIST_TO_RESP frame.
1149    pub async fn list_relations_to_frames(
1150        &self,
1151        request: &RelationListToRequest,
1152    ) -> Result<Vec<RelationListToResponseFrame>> {
1153        self.streamed(
1154            Opcode::RelationListToReq,
1155            Opcode::RelationListToResp,
1156            "RELATION_LIST_TO_RESP",
1157            request,
1158        )
1159        .await
1160    }
1161
1162    /// List schema versions in a namespace (SCHEMA_LIST), flattening every
1163    /// streamed frame's `items`. For the raw frames, use
1164    /// [`Self::list_schemas_frames`].
1165    pub async fn list_schemas(
1166        &self,
1167        request: &SchemaListRequest,
1168    ) -> Result<Vec<SchemaListItemWire>> {
1169        let frames = self.list_schemas_frames(request).await?;
1170        let mut items = Vec::new();
1171        for frame in frames {
1172            items.extend(frame.items);
1173        }
1174        Ok(items)
1175    }
1176
1177    /// List schema versions, returning each decoded SCHEMA_LIST_RESP frame.
1178    pub async fn list_schemas_frames(
1179        &self,
1180        request: &SchemaListRequest,
1181    ) -> Result<Vec<SchemaListResponseFrame>> {
1182        self.streamed(
1183            Opcode::SchemaListReq,
1184            Opcode::SchemaListResp,
1185            "SCHEMA_LIST_RESP",
1186            request,
1187        )
1188        .await
1189    }
1190
1191    /// Begin a transaction (TXN_BEGIN). The client mints `txn_id`; subsequent
1192    /// writes carry it to enroll in the transaction until commit or abort.
1193    pub async fn txn_begin(&self, request: &TxnBeginRequest) -> Result<TxnBeginResponse> {
1194        self.unary(
1195            Opcode::TxnBegin,
1196            Opcode::TxnBeginResp,
1197            "TXN_BEGIN_RESP",
1198            request,
1199        )
1200        .await
1201    }
1202
1203    /// Commit a transaction (TXN_COMMIT). The response reports how many
1204    /// operations were applied.
1205    pub async fn txn_commit(&self, request: &TxnCommitRequest) -> Result<TxnCommitResponse> {
1206        self.unary(
1207            Opcode::TxnCommit,
1208            Opcode::TxnCommitResp,
1209            "TXN_COMMIT_RESP",
1210            request,
1211        )
1212        .await
1213    }
1214
1215    /// Abort a transaction (TXN_ABORT), discarding its buffered operations.
1216    pub async fn txn_abort(&self, request: &TxnAbortRequest) -> Result<TxnAbortResponse> {
1217        self.unary(
1218            Opcode::TxnAbort,
1219            Opcode::TxnAbortResp,
1220            "TXN_ABORT_RESP",
1221            request,
1222        )
1223        .await
1224    }
1225
1226    /// Provision the caller's effective space explicitly (SPACE_CREATE).
1227    /// Idempotent: a create for an existing space returns the existing row
1228    /// with `created = false`.
1229    pub async fn create_space(&self, request: &SpaceCreateRequest) -> Result<SpaceCreateResponse> {
1230        self.unary(
1231            Opcode::SpaceCreateReq,
1232            Opcode::SpaceCreateResp,
1233            "SPACE_CREATE_RESP",
1234            request,
1235        )
1236        .await
1237    }
1238
1239    /// List the caller's namespace's spaces (SPACE_LIST). `cross_shard_complete`
1240    /// is `false` while the listing covers only the caller's shard.
1241    pub async fn list_spaces(&self, request: &SpaceListRequest) -> Result<SpaceListResponse> {
1242        self.unary(
1243            Opcode::SpaceListReq,
1244            Opcode::SpaceListResp,
1245            "SPACE_LIST_RESP",
1246            request,
1247        )
1248        .await
1249    }
1250
1251    /// Erase the caller's effective space and every row under it
1252    /// (SPACE_DELETE). Hard/immediate GDPR erasure.
1253    pub async fn delete_space(&self, request: &SpaceDeleteRequest) -> Result<SpaceDeleteResponse> {
1254        self.unary(
1255            Opcode::SpaceDeleteReq,
1256            Opcode::SpaceDeleteResp,
1257            "SPACE_DELETE_RESP",
1258            request,
1259        )
1260        .await
1261    }
1262
1263    /// Provision a session under the caller's effective space explicitly
1264    /// (SESSION_CREATE). Idempotent.
1265    pub async fn create_session(
1266        &self,
1267        request: &SessionCreateRequest,
1268    ) -> Result<SessionCreateResponse> {
1269        self.unary(
1270            Opcode::SessionCreateReq,
1271            Opcode::SessionCreateResp,
1272            "SESSION_CREATE_RESP",
1273            request,
1274        )
1275        .await
1276    }
1277
1278    /// List one space's sessions newest-first (SESSION_LIST). A first-class
1279    /// end-user feature — the client's session picker reads this.
1280    pub async fn list_sessions(&self, request: &SessionListRequest) -> Result<SessionListResponse> {
1281        self.unary(
1282            Opcode::SessionListReq,
1283            Opcode::SessionListResp,
1284            "SESSION_LIST_RESP",
1285            request,
1286        )
1287        .await
1288    }
1289
1290    /// Delete a session's memories + graph rows (SESSION_DELETE). Soft by
1291    /// default (7-day grace); `hard = true` zeroes immediately. The default
1292    /// session (`session_id = 0`) is non-deletable.
1293    pub async fn delete_session(
1294        &self,
1295        request: &SessionDeleteRequest,
1296    ) -> Result<SessionDeleteResponse> {
1297        self.unary(
1298            Opcode::SessionDeleteReq,
1299            Opcode::SessionDeleteResp,
1300            "SESSION_DELETE_RESP",
1301            request,
1302        )
1303        .await
1304    }
1305
1306    /// Open a long-lived change-feed subscription (SUBSCRIBE). Returns a
1307    /// [`Subscription`] handle the caller drains with `next().await`; call
1308    /// `unsubscribe().await` for a clean teardown. The server pushes
1309    /// `SUBSCRIBE_EVENT` frames until the subscription is torn down, so this
1310    /// does not block on a single response.
1311    pub async fn subscribe(&self, request: &SubscribeRequest) -> Result<Subscription<TcpStream>> {
1312        self.conn.subscribe(request).await
1313    }
1314
1315    /// Send a CBOR-encoded request and decode every streamed response frame up
1316    /// to and including EOS, asserting each frame's opcode. The shape every
1317    /// LIST verb's `*_frames` method shares (mirrors [`Self::recall_frames`]).
1318    async fn streamed<Req, Frame>(
1319        &self,
1320        req_opcode: Opcode,
1321        resp_opcode: Opcode,
1322        resp_name: &str,
1323        request: &Req,
1324    ) -> Result<Vec<Frame>>
1325    where
1326        Req: serde::Serialize,
1327        Frame: serde::de::DeserializeOwned,
1328    {
1329        let frames = self
1330            .conn
1331            .request(req_opcode, to_cbor_bytes(request))
1332            .await?;
1333        let mut out = Vec::with_capacity(frames.len());
1334        for frame in frames {
1335            if frame.opcode != resp_opcode as u16 {
1336                return Err(BrainError::Protocol(format!(
1337                    "expected {resp_name} ({:#06x}), got {:#06x}",
1338                    resp_opcode as u16, frame.opcode
1339                )));
1340            }
1341            out.push(from_cbor_bytes(&frame.payload)?);
1342        }
1343        Ok(out)
1344    }
1345
1346    /// Send a CBOR-encoded request and decode a single typed response frame,
1347    /// asserting the response opcode. The shape every single-shot verb shares.
1348    async fn unary<Req, Resp>(
1349        &self,
1350        req_opcode: Opcode,
1351        resp_opcode: Opcode,
1352        resp_name: &str,
1353        request: &Req,
1354    ) -> Result<Resp>
1355    where
1356        Req: serde::Serialize,
1357        Resp: serde::de::DeserializeOwned,
1358    {
1359        let frame = self
1360            .conn
1361            .request_one(req_opcode, to_cbor_bytes(request))
1362            .await?;
1363        if frame.opcode != resp_opcode as u16 {
1364            return Err(BrainError::Protocol(format!(
1365                "expected {resp_name} ({:#06x}), got {:#06x}",
1366                resp_opcode as u16, frame.opcode
1367            )));
1368        }
1369        Ok(from_cbor_bytes(&frame.payload)?)
1370    }
1371
1372    /// Send BYE and drop the connection.
1373    pub async fn close(self) -> Result<()> {
1374        self.conn.send_bye().await
1375    }
1376}
1377
1378/// Mint a fresh 16-byte identifier from a time-ordered UUIDv7.
1379#[must_use]
1380pub fn new_id() -> [u8; 16] {
1381    *uuid::Uuid::now_v7().as_bytes()
1382}
1383
1384/// Root namespace UUID for space-id derivation: the 16 bytes of the ASCII
1385/// string `"brain-space-root"`. Must match the server's frozen seed.
1386const BRAIN_ROOT_NAMESPACE_UUID: uuid::Uuid = uuid::Uuid::from_bytes([
1387    0x6b, 0x72, 0x61, 0x69, 0x6e, 0x2d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x2d, 0x72, 0x6f, 0x6f, 0x74,
1388]);
1389
1390/// Derive the 16-byte storage space id from a structured space string, exactly
1391/// as the server does at request ingress: `UUIDv5(UUIDv5(ROOT, namespace),
1392/// space)`. Folding the namespace makes equal strings under different
1393/// namespaces diverge. Clients only ever *send* the space string; this is for
1394/// the rare cases that need the resolved id locally — e.g. a `SubscriptionFilter`
1395/// whose `spaces` names the effective space by its resolved id. The seed is
1396/// frozen and pinned by a golden test on both sides; changing it re-keys every
1397/// space.
1398#[must_use]
1399pub fn derive_space_id(namespace: &str, space: &str) -> [u8; 16] {
1400    let ns = uuid::Uuid::new_v5(&BRAIN_ROOT_NAMESPACE_UUID, namespace.as_bytes());
1401    *uuid::Uuid::new_v5(&ns, space.as_bytes()).as_bytes()
1402}
1403
1404#[cfg(test)]
1405mod space_id_tests {
1406    use super::derive_space_id;
1407
1408    #[test]
1409    fn derive_space_id_matches_server_golden() {
1410        // The same frozen vector the server pins in brain-core: changing the
1411        // seed on either side would break client/server agreement.
1412        let id = derive_space_id("acme", "support-bot:user123");
1413        assert_eq!(
1414            uuid::Uuid::from_bytes(id).to_string(),
1415            "119061cc-b6db-5cde-8edd-0cefa33452eb"
1416        );
1417    }
1418
1419    #[test]
1420    fn same_space_diverges_across_namespaces() {
1421        assert_ne!(derive_space_id("ns_a", "u1"), derive_space_id("ns_b", "u1"));
1422    }
1423}