Skip to main content

laser_wire/
kv.rs

1use crate::error::InvalidError;
2use crate::limits::MAX_NAMESPACE_BYTES;
3use crate::mutation::MutationPosition;
4use serde::{Deserialize, Serialize};
5
6/// The memory scope a read-view row carries, stamped by the fold from the
7/// record's headers so recall reconstructs a memory item and narrows by scope.
8/// Absent on a generic key-value entry, which carries no scope.
9#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub struct MemoryRowScope {
11    /// The item kind word, from the record body.
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub kind: Option<String>,
14    /// The producing agent, from `gen_ai.agent.id`.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub agent: Option<String>,
17    /// The user scope layer, from `agdx.mem.user`.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub user: Option<String>,
20    /// The app scope layer, from `agdx.mem.app`.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub app: Option<String>,
23    /// The conversation that wrote the record, from `gen_ai.conversation.id`.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub conversation: Option<String>,
26    /// The origin log record this memory item was folded from (stream, topic,
27    /// partition, offset), so a reader can navigate back to the source message
28    /// while it is still on the log. Absent when unknown.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub source: Option<crate::graph::SourceRef>,
31}
32
33/// One stored entry: an arbitrary-bytes key and value plus optional expiry. The
34/// key and value are owned `Vec<u8>`, so the public API never leaks the `bytes`
35/// crate, and they ride the wire as CBOR byte strings, byte-exact.
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37pub struct KvEntry {
38    #[serde(with = "crate::encoding::bin_bytes")]
39    pub key: Vec<u8>,
40    #[serde(with = "crate::encoding::bin_bytes")]
41    pub value: Vec<u8>,
42    /// Absolute expiry in epoch microseconds, or `None` for no expiry. Expired
43    /// entries are hidden on read.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub expires_at_micros: Option<u64>,
46    /// Optimistic-concurrency version, assigned by the store and bumped on every
47    /// successful mutation of this key. The token a [`KvCas`] precondition
48    /// matches against. A versioned managed backend reports `>= 1` for a live
49    /// entry and reserves `0` solely for "unversioned": a store that does not
50    /// track versions, or an entry written before versioning. A caller must
51    /// therefore never treat `0` as a valid compare token. The field is skipped
52    /// on the wire when `0` so those entries stay byte-identical.
53    #[serde(default, skip_serializing_if = "is_zero_u64")]
54    pub version: u64,
55    /// The memory scope, present only on a memory read-view row. Recall reads it
56    /// to rebuild the item and filter by scope. A generic entry leaves it `None`.
57    /// Boxed so a generic entry stays small (the scope is five optional strings).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub scope: Option<Box<MemoryRowScope>>,
60    /// The origin log record this entry was folded from (stream, topic,
61    /// partition, offset). Every managed write goes log-first through a
62    /// mutation topic, so a stored entry points back to the record that wrote
63    /// it, the same provenance a memory row carries. Absent on an entry
64    /// written before provenance stamping, or read from a store that does not
65    /// track it. Boxed, like `scope`, so a generic entry stays small.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub source: Option<Box<crate::graph::SourceRef>>,
68}
69
70fn is_zero_u64(value: &u64) -> bool {
71    *value == 0
72}
73
74impl KvEntry {
75    /// The key decoded as UTF-8, or `None` when it is not valid UTF-8. Keys are
76    /// arbitrary bytes, so a binary key has no string form.
77    pub fn key_str(&self) -> Option<&str> {
78        std::str::from_utf8(&self.key).ok()
79    }
80}
81
82/// A page of scanned entries plus the cursor to resume after the last one.
83/// `cursor` is `None` when the scan reached the end.
84#[derive(Clone, Debug, Default, Serialize, Deserialize)]
85pub struct KvPage {
86    pub entries: Vec<KvEntry>,
87    #[serde(
88        default,
89        skip_serializing_if = "Option::is_none",
90        with = "crate::encoding::opt_bin_bytes"
91    )]
92    pub cursor: Option<Vec<u8>>,
93}
94
95/// Request to read the value at `key` in `namespace`. With `if_none_match` set to
96/// a version, the read returns [`KvOutcome::NotModified`] instead of the value
97/// when the live version matches (a conditional GET), so an up-to-date cache
98/// skips the body transfer.
99///
100/// With `min_position` set, the read is barriered: the answering fold must have
101/// applied at least that [`MutationPosition`] before reading, so a takeover
102/// reader cannot observe a fold that predates its own lease acquisition. The
103/// server waits a short bound and answers [`KvError::Stale`] when the fold does
104/// not catch up, never an absent value. Only a deployment advertising the
105/// `KV_FENCED_LEASES` capability honors the barrier: a pre-fencing plane
106/// ignores the field, so a client must not send it unless the bit is
107/// advertised. Absent keeps today's immediate local read, byte-identical.
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct KvGet {
110    pub v: u32,
111    pub namespace: String,
112    #[serde(with = "crate::encoding::bin_bytes")]
113    pub key: Vec<u8>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub if_none_match: Option<u64>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub min_position: Option<MutationPosition>,
118}
119
120/// Request to write `value` at `key` in `namespace`, with an optional expiry.
121#[derive(Clone, Debug, Serialize, Deserialize)]
122pub struct KvSet {
123    pub v: u32,
124    pub namespace: String,
125    #[serde(with = "crate::encoding::bin_bytes")]
126    pub key: Vec<u8>,
127    #[serde(with = "crate::encoding::bin_bytes")]
128    pub value: Vec<u8>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub expires_at_micros: Option<u64>,
131}
132
133/// The precondition a [`KvCas`] write must satisfy to apply. The compare half
134/// of compare-and-swap: lock-free optimistic concurrency for callers contending
135/// on one key.
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
137pub enum CasExpect {
138    /// Apply only if the key currently holds this exact [`KvEntry::version`].
139    Match(u64),
140    /// Apply only if the key does not currently exist (a create-if-absent).
141    Absent,
142}
143
144/// Compare-and-swap write at `key` in `namespace`: apply `value` (with optional
145/// expiry) only if `expect` holds, else fail with
146/// [`KvError::VersionConflict`]. The swap half of optimistic concurrency, paired
147/// with [`KvEntry::version`] as the compare token.
148#[derive(Clone, Debug, Serialize, Deserialize)]
149pub struct KvCas {
150    pub v: u32,
151    pub namespace: String,
152    #[serde(with = "crate::encoding::bin_bytes")]
153    pub key: Vec<u8>,
154    #[serde(with = "crate::encoding::bin_bytes")]
155    pub value: Vec<u8>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub expires_at_micros: Option<u64>,
158    pub expect: CasExpect,
159}
160
161/// Fenced compare-and-swap: the [`KvCas`] write and precondition, applied in
162/// one backend transaction that also requires a **live** lease at
163/// (`fence_namespace`, `fence_key`) and a fence sequence still equal to
164/// `fence_token`. A released or expired lease, or a stale token, maps to
165/// [`KvError::LeaseLost`] even before another holder acquires; a failed
166/// precondition to [`KvError::VersionConflict`]. Version
167/// [`crate::codes::KV_LEASE_OP_VERSION`]: the pre-fencing v1 shape (no
168/// coordination namespace, fence-only gate) fails decode and any other `v`
169/// fails [`crate::validate::Validate`], both fail-closed.
170#[derive(Clone, Debug, Serialize, Deserialize)]
171pub struct KvCasFenced {
172    pub v: u32,
173    pub namespace: String,
174    #[serde(with = "crate::encoding::bin_bytes")]
175    pub key: Vec<u8>,
176    #[serde(with = "crate::encoding::bin_bytes")]
177    pub value: Vec<u8>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub expires_at_micros: Option<u64>,
180    pub expect: CasExpect,
181    /// The coordination namespace holding the lease and fence rows. Authorized
182    /// separately from the target `namespace` (`kv_fence:read`), so a state
183    /// writer can present a fence without being able to mutate it.
184    pub fence_namespace: String,
185    /// The lease key whose live row and fence sequence gate this write.
186    #[serde(with = "crate::encoding::bin_bytes")]
187    pub fence_key: Vec<u8>,
188    /// The fencing token: the fence-sequence value the holder was granted.
189    /// Strictly monotonic per key, never the lease-row version.
190    pub fence_token: u64,
191}
192
193/// Request to remove `key` from `namespace`. With `if_match` set to a version,
194/// the delete applies only when the live version matches (a conditional delete),
195/// returning [`KvError::VersionConflict`] otherwise.
196#[derive(Clone, Debug, Serialize, Deserialize)]
197pub struct KvDelete {
198    pub v: u32,
199    pub namespace: String,
200    #[serde(with = "crate::encoding::bin_bytes")]
201    pub key: Vec<u8>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub if_match: Option<u64>,
204}
205
206/// Request to test presence and read metadata without the value. The cheap way
207/// to check a precondition before transferring a large value (the formal
208/// `EXISTS` primitive).
209#[derive(Clone, Debug, Serialize, Deserialize)]
210pub struct KvExists {
211    pub v: u32,
212    pub namespace: String,
213    #[serde(with = "crate::encoding::bin_bytes")]
214    pub key: Vec<u8>,
215}
216
217/// Request to set, refresh, or clear a key's expiry in place without rewriting
218/// its value. `expires_at_micros` of `None` clears the expiry (the formal
219/// `EXPIRE` primitive).
220#[derive(Clone, Debug, Serialize, Deserialize)]
221pub struct KvExpire {
222    pub v: u32,
223    pub namespace: String,
224    #[serde(with = "crate::encoding::bin_bytes")]
225    pub key: Vec<u8>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub expires_at_micros: Option<u64>,
228}
229
230/// Request to apply a merge patch to a structured value without transferring the
231/// whole object. `patch` is a codec-specific patch document (the `content_type`
232/// names the format). With `if_match` set, the patch applies only at that version
233/// (the formal `PATCH` primitive).
234#[derive(Clone, Debug, Serialize, Deserialize)]
235pub struct KvPatch {
236    pub v: u32,
237    pub namespace: String,
238    #[serde(with = "crate::encoding::bin_bytes")]
239    pub key: Vec<u8>,
240    #[serde(with = "crate::encoding::bin_bytes")]
241    pub patch: Vec<u8>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub if_match: Option<u64>,
244}
245
246/// Request to copy the value at `key` to `to_key` (optionally into another
247/// namespace via `to_namespace`) in a single backend transaction. `Committed`
248/// on success, `NotFound` when the source is absent. The destination is
249/// overwritten (a guarded copy composes `exists` + `cas` instead).
250#[derive(Clone, Debug, Serialize, Deserialize)]
251pub struct KvCopy {
252    pub v: u32,
253    pub namespace: String,
254    #[serde(with = "crate::encoding::bin_bytes")]
255    pub key: Vec<u8>,
256    /// The destination namespace. Absent means the source `namespace`.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub to_namespace: Option<String>,
259    #[serde(with = "crate::encoding::bin_bytes")]
260    pub to_key: Vec<u8>,
261}
262
263/// Request to move the value at `key` to `to_key`: copy plus delete of the
264/// source, one backend transaction, the same outcomes as [`KvCopy`].
265#[derive(Clone, Debug, Serialize, Deserialize)]
266pub struct KvMove {
267    pub v: u32,
268    pub namespace: String,
269    #[serde(with = "crate::encoding::bin_bytes")]
270    pub key: Vec<u8>,
271    /// The destination namespace. Absent means the source `namespace`.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub to_namespace: Option<String>,
274    #[serde(with = "crate::encoding::bin_bytes")]
275    pub to_key: Vec<u8>,
276}
277
278/// Request to acquire a revocable lease (a bounded-TTL distributed lock) on
279/// `key`. On success the store atomically creates the live lease row and bumps
280/// the never-expiring fence sequence; the holder gets the new `lease_token`
281/// and the granted TTL (the formal `LEASE` primitive). A live lease always
282/// conflicts: renewal is [`KvLeaseRenew`], never re-acquisition. Version
283/// [`crate::codes::KV_LEASE_OP_VERSION`]: the v1 shape (no holder identity)
284/// fails decode fail-closed.
285#[derive(Clone, Debug, Serialize, Deserialize)]
286pub struct KvLease {
287    pub v: u32,
288    pub namespace: String,
289    #[serde(with = "crate::encoding::bin_bytes")]
290    pub key: Vec<u8>,
291    /// Requested maximum lifetime, within
292    /// [`MIN_LEASE_TTL_MICROS`](crate::limits::MIN_LEASE_TTL_MICROS)`..=`[`MAX_LEASE_TTL_MICROS`](crate::limits::MAX_LEASE_TTL_MICROS).
293    /// A successful grant is nonzero and never exceeds this duration, so this
294    /// remains a conservative expiry bound when the grant reply is lost. The
295    /// store may grant less; it never grants more, which is why the range is
296    /// validated on the request rather than clamped.
297    pub lease_ttl_micros: u64,
298    /// Stable identity of the acquiring holder (a node or worker id, never a
299    /// connection-scoped value). Renewal and release must present the same
300    /// holder, so a different process cannot extend or drop a lease it does
301    /// not hold.
302    pub holder_id: String,
303    /// The user the lease protects mutations for. `None` leases for the
304    /// authenticated caller. `Some` is a delegated acquisition (an operator
305    /// leasing on behalf of a runtime user) and requires the `kv_lease:admin`
306    /// grant; the fence then validates against the subject's rows.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub subject_user_id: Option<u32>,
309}
310
311/// Request to extend a held lease without changing its token (the formal
312/// `RENEW` primitive). The store requires the same holder (and subject), a
313/// live lease row, and the current fence token, then moves the expiry forward
314/// and returns [`KvOutcome::Renewed`] with the same token: renewal never bumps
315/// the fence. An expired or re-acquired lease answers
316/// [`KvError::LeaseLost`], so a deposed holder learns immediately.
317#[derive(Clone, Debug, Serialize, Deserialize)]
318pub struct KvLeaseRenew {
319    pub v: u32,
320    pub namespace: String,
321    #[serde(with = "crate::encoding::bin_bytes")]
322    pub key: Vec<u8>,
323    pub holder_id: String,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub subject_user_id: Option<u32>,
326    pub lease_token: u64,
327    /// Requested maximum extended lifetime, within the same
328    /// [`MIN_LEASE_TTL_MICROS`](crate::limits::MIN_LEASE_TTL_MICROS)`..=`[`MAX_LEASE_TTL_MICROS`](crate::limits::MAX_LEASE_TTL_MICROS)
329    /// range as acquisition. A successful renewal is nonzero and never exceeds
330    /// this duration.
331    pub lease_ttl_micros: u64,
332}
333
334/// Request to release a held lease early, presenting the same `holder_id` and
335/// the `lease_token` the grant returned (the formal `RELEASE` primitive). The
336/// token is validated against the fence sequence, never the lease-row version
337/// (which resets on re-acquire), and the live row is physically removed in the
338/// same transaction. A missing or expired row under a still-current fence is
339/// the idempotent [`KvOutcome::Released`]`(false)`; a stale fence is
340/// [`KvError::LeaseLost`].
341#[derive(Clone, Debug, Serialize, Deserialize)]
342pub struct KvRelease {
343    pub v: u32,
344    pub namespace: String,
345    #[serde(with = "crate::encoding::bin_bytes")]
346    pub key: Vec<u8>,
347    pub lease_token: u64,
348    pub holder_id: String,
349}
350
351/// One key's metadata without its value: version, expiry, and value size. The
352/// reply to [`KvExists`].
353#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
354pub struct KvMetadata {
355    pub version: u64,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub expires_at_micros: Option<u64>,
358    pub size_bytes: usize,
359}
360
361/// Request to list every namespace that holds at least one entry for the caller.
362#[derive(Clone, Debug, Serialize, Deserialize)]
363pub struct KvNamespaces {
364    pub v: u32,
365}
366
367/// One namespace summary in a `Namespaces` reply.
368#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
369pub struct KvNamespaceInfo {
370    pub namespace: String,
371    pub entries: usize,
372}
373
374/// Request to list entries in `namespace`. With no bounds it lists the whole
375/// namespace. `prefix` matches keys that start with it in byte order. `start`
376/// and `end` bound an inclusive-start, exclusive-end key range. `key_contains`
377/// keeps only keys that are valid UTF-8 and contain the substring (binary keys
378/// are skipped). All bounds compose.
379#[derive(Clone, Debug, Serialize, Deserialize)]
380pub struct KvScan {
381    pub v: u32,
382    pub namespace: String,
383    #[serde(
384        default,
385        skip_serializing_if = "Option::is_none",
386        with = "crate::encoding::opt_bin_bytes"
387    )]
388    pub prefix: Option<Vec<u8>>,
389    #[serde(
390        default,
391        skip_serializing_if = "Option::is_none",
392        with = "crate::encoding::opt_bin_bytes"
393    )]
394    pub start: Option<Vec<u8>>,
395    #[serde(
396        default,
397        skip_serializing_if = "Option::is_none",
398        with = "crate::encoding::opt_bin_bytes"
399    )]
400    pub end: Option<Vec<u8>>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub key_contains: Option<String>,
403    /// The conversation lens: keep only rows the given conversation wrote (the
404    /// text form of its `gen_ai.conversation.id`). The memory read view stamps
405    /// this on each record it materializes, generic key-value rows leave it
406    /// unset, so a scan narrows to one conversation's memory. Absent on the wire
407    /// when unfiltered, so a pre-lens scan stays byte-identical.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub conversation: Option<String>,
410    pub limit: usize,
411    #[serde(
412        default,
413        skip_serializing_if = "Option::is_none",
414        with = "crate::encoding::opt_bin_bytes"
415    )]
416    pub cursor: Option<Vec<u8>>,
417}
418
419/// Request to bulk-delete entries in `namespace` matching the same composed
420/// bounds as a scan (`prefix`/`start`/`end`/`key_contains`). With no bounds it
421/// clears the whole namespace. No `limit`/`cursor`, and expiry is ignored (a
422/// matching expired entry is removed too).
423#[derive(Clone, Debug, Serialize, Deserialize)]
424pub struct KvDeleteMany {
425    pub v: u32,
426    pub namespace: String,
427    #[serde(
428        default,
429        skip_serializing_if = "Option::is_none",
430        with = "crate::encoding::opt_bin_bytes"
431    )]
432    pub prefix: Option<Vec<u8>>,
433    #[serde(
434        default,
435        skip_serializing_if = "Option::is_none",
436        with = "crate::encoding::opt_bin_bytes"
437    )]
438    pub start: Option<Vec<u8>>,
439    #[serde(
440        default,
441        skip_serializing_if = "Option::is_none",
442        with = "crate::encoding::opt_bin_bytes"
443    )]
444    pub end: Option<Vec<u8>>,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub key_contains: Option<String>,
447    /// The conversation lens over a bulk delete: clear only the rows a given
448    /// conversation wrote. See [`KvScan::conversation`]. Absent when unfiltered.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub conversation: Option<String>,
451}
452
453/// The result of a key-value operation: `Ok` with the operation's outcome, or
454/// `Err` with a structured failure.
455#[derive(Clone, Debug, Serialize, Deserialize)]
456#[non_exhaustive]
457pub enum KvReply {
458    Ok(KvOutcome),
459    Err(KvError),
460}
461
462/// The successful outcome of a KV command, shaped per op.
463#[derive(Clone, Debug, Serialize, Deserialize)]
464#[non_exhaustive]
465pub enum KvOutcome {
466    /// `get`: the live entry, or `None` when absent or expired.
467    Value(Option<KvEntry>),
468    /// `set`: the write was applied.
469    Written,
470    /// `cas`: the compare-and-swap applied. Carries the entry's new version, so
471    /// the caller can chain a further conditional write without a re-read.
472    Committed { version: u64 },
473    /// `delete`: `true` when a live entry was removed, `false` when none existed.
474    Deleted(bool),
475    /// `delete_many`: the number of entries removed by a filtered bulk delete.
476    DeletedMany(usize),
477    /// `scan`: one page of entries.
478    Page(KvPage),
479    /// `namespaces`: every namespace holding at least one entry for the
480    /// caller with its entry count, sorted by name.
481    Namespaces(Vec<KvNamespaceInfo>),
482    /// `get` with `if_none_match`: the live version matched, so the body is
483    /// unchanged and not transferred (a conditional-GET hit).
484    NotModified,
485    /// `exists`: the key's metadata, or `None` when absent or expired.
486    Metadata(Option<KvMetadata>),
487    /// `expire` / `patch`: applied, carrying the entry's version. `expire` leaves
488    /// the version unchanged, `patch` bumps it.
489    Versioned { version: u64 },
490    /// `lease`: the lease was granted, carrying the fencing token, the granted
491    /// nonzero TTL (which the store may clamp down from, but never above, the
492    /// request), and the mutation
493    /// position at which the answering fold applied the grant, the barrier a
494    /// takeover read passes back as [`KvGet::min_position`].
495    Leased {
496        lease_token: u64,
497        granted_ttl_micros: u64,
498        position: MutationPosition,
499    },
500    /// `lease_renew`: the lease was extended, carrying the unchanged token,
501    /// the granted TTL, and the renewal's applied mutation position. A caller
502    /// treating an unexpected variant here as success would trust an
503    /// unextended deadline, so decode fail-closed.
504    Renewed {
505        lease_token: u64,
506        granted_ttl_micros: u64,
507        position: MutationPosition,
508    },
509    /// `release`: `true` when a held lease was released, `false` when none was
510    /// held (idempotent release).
511    Released(bool),
512}
513
514/// Why a key-value operation failed.
515#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
516#[non_exhaustive]
517pub enum KvError {
518    #[error("kv not supported: {0}")]
519    Unsupported(String),
520    #[error("invalid key: {0}")]
521    InvalidKey(String),
522    /// The request named a namespace that fails [`validate_namespace`].
523    #[error("invalid namespace: {0}")]
524    InvalidNamespace(String),
525    #[error("{what} is {size}B, exceeds cap {cap}B")]
526    TooLarge {
527        what: String,
528        size: usize,
529        cap: usize,
530    },
531    #[error("kv backend error: {0}")]
532    Backend(String),
533    /// The request could not be served right now and the identical request may
534    /// succeed later: the store was momentarily out of reach, a connection was
535    /// refused or dropped, or a concurrency conflict was aborted. Retry with
536    /// backoff rather than treating it as a fault.
537    #[error("temporarily unavailable: {0}")]
538    Unavailable(String),
539    #[error("unsupported kv op version (expected {expected}, got {got})")]
540    Version { expected: u32, got: u32 },
541    /// A [`KvCas`] precondition was not met. `current` is the key's present
542    /// version (`Some`) or `None` when the key does not exist, so the caller can
543    /// re-read and retry, or learn that an `Absent` precondition lost a race.
544    #[error("kv version conflict (current: {current:?})")]
545    VersionConflict { current: Option<u64> },
546    /// A held [`KvLease`] expired, was released, or was re-acquired by another
547    /// holder, so its token no longer protects mutations. The caller must stop
548    /// its protected work; a new epoch needs a fresh acquisition.
549    #[error("kv lease lost")]
550    LeaseLost,
551    /// A barriered [`KvGet`] could not be answered: the local fold did not
552    /// reach `required` within the server's wait bound, or the request's topic
553    /// generation does not match the fold's. Explicitly never mapped to an
554    /// absent value; retry against a caught-up fold.
555    #[error("kv read barrier not reached (required: {required:?})")]
556    Stale { required: MutationPosition },
557    /// An in-place op (`expire`, `patch`) targeted a key that is absent or
558    /// expired (the formal `NOT_FOUND`).
559    #[error("kv key not found")]
560    NotFound,
561    /// This plane does not own the mutation partition for the key. The caller
562    /// may retry after the streaming client refreshes partition leadership.
563    #[error("not the partition leader for this key")]
564    NotLeader,
565}
566
567/// The canonical namespace rule, shared by the SDK client edge and every
568/// serving tier: non-empty, at most [`MAX_NAMESPACE_BYTES`] bytes, no ASCII
569/// control characters. The charset is otherwise open, so `/`-style hierarchy
570/// stays legal. The bound is a size and sanity cap, not a safelist.
571pub fn validate_namespace(namespace: &str) -> Result<(), InvalidError> {
572    if namespace.is_empty() {
573        return Err(InvalidError::new("namespace must not be empty"));
574    }
575    if namespace.len() > MAX_NAMESPACE_BYTES {
576        return Err(InvalidError::new(format!(
577            "namespace is {}B, exceeds cap {MAX_NAMESPACE_BYTES}B",
578            namespace.len()
579        )));
580    }
581    if namespace.bytes().any(|byte| byte.is_ascii_control()) {
582        return Err(InvalidError::new(
583            "namespace must not contain ASCII control characters",
584        ));
585    }
586    Ok(())
587}
588
589#[cfg(all(test, feature = "cbor"))]
590mod tests {
591    use super::*;
592    use crate::codes::KV_OP_VERSION;
593    use crate::framing::{decode_named, encode_named};
594
595    #[test]
596    fn given_kv_delete_many_when_round_tripped_then_should_preserve_bounds() {
597        let request = KvDeleteMany {
598            v: KV_OP_VERSION,
599            namespace: "sessions".to_owned(),
600            prefix: Some(b"user:".to_vec()),
601            start: None,
602            end: None,
603            key_contains: Some("stale".to_owned()),
604            conversation: None,
605        };
606        let bytes = encode_named(&request).expect("serializes");
607        let back: KvDeleteMany = decode_named(&bytes).expect("deserializes");
608        assert_eq!(back.prefix, Some(b"user:".to_vec()));
609        assert_eq!(back.key_contains.as_deref(), Some("stale"));
610    }
611
612    #[test]
613    fn given_kv_deleted_many_reply_when_round_tripped_then_should_preserve_count() {
614        let reply = KvReply::Ok(KvOutcome::DeletedMany(7));
615        let bytes = encode_named(&reply).expect("serializes");
616        let back: KvReply = decode_named(&bytes).expect("deserializes");
617        match back {
618            KvReply::Ok(KvOutcome::DeletedMany(n)) => assert_eq!(n, 7),
619            other => panic!("expected DeletedMany, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn given_a_set_request_when_round_tripped_then_should_preserve_value_and_expiry() {
625        let request = KvSet {
626            v: KV_OP_VERSION,
627            namespace: "sessions".to_owned(),
628            key: b"user:42".to_vec(),
629            value: b"online".to_vec(),
630            expires_at_micros: Some(1_700_000_000_000_000),
631        };
632        let bytes = encode_named(&request).expect("the request serializes");
633        let back: KvSet = decode_named(&bytes).expect("the request deserializes");
634        assert_eq!(back.key, b"user:42");
635        assert_eq!(back.value, b"online");
636        assert_eq!(back.expires_at_micros, Some(1_700_000_000_000_000));
637    }
638
639    #[test]
640    fn given_a_binary_key_entry_when_round_tripped_then_should_preserve_raw_bytes() {
641        let reply = KvReply::Ok(KvOutcome::Value(Some(KvEntry {
642            key: vec![0xff, 0x00, 0xfe],
643            value: vec![0x00, 0x01, 0x02],
644            expires_at_micros: None,
645            version: 0,
646            scope: None,
647            source: None,
648        })));
649        let bytes = encode_named(&reply).expect("the reply serializes");
650        let back: KvReply = decode_named(&bytes).expect("the reply deserializes");
651        let KvReply::Ok(KvOutcome::Value(Some(entry))) = back else {
652            panic!("expected an Ok(Value(Some)) reply");
653        };
654        assert_eq!(entry.key, vec![0xff, 0x00, 0xfe]);
655        assert_eq!(entry.key_str(), None, "non-UTF-8 key has no string form");
656        assert_eq!(entry.value, vec![0x00, 0x01, 0x02]);
657    }
658
659    #[test]
660    fn given_a_scan_page_when_round_tripped_then_should_preserve_cursor() {
661        let reply = KvReply::Ok(KvOutcome::Page(KvPage {
662            entries: vec![KvEntry {
663                key: b"a".to_vec(),
664                value: b"1".to_vec(),
665                expires_at_micros: None,
666                version: 0,
667                scope: None,
668                source: None,
669            }],
670            cursor: Some(b"a".to_vec()),
671        }));
672        let bytes = encode_named(&reply).expect("serializes");
673        let back: KvReply = decode_named(&bytes).expect("deserializes");
674        let KvReply::Ok(KvOutcome::Page(page)) = back else {
675            panic!("expected an Ok(Page) reply");
676        };
677        assert_eq!(page.entries.len(), 1);
678        assert_eq!(page.entries[0].key_str(), Some("a"));
679        assert_eq!(page.cursor.as_deref(), Some(b"a".as_ref()));
680    }
681
682    #[test]
683    fn given_a_cas_request_when_round_tripped_then_should_preserve_the_precondition() {
684        for expect in [CasExpect::Match(7), CasExpect::Absent] {
685            let request = KvCas {
686                v: KV_OP_VERSION,
687                namespace: "counters".to_owned(),
688                key: b"hits".to_vec(),
689                value: b"42".to_vec(),
690                expires_at_micros: None,
691                expect,
692            };
693            let bytes = encode_named(&request).expect("serializes");
694            let back: KvCas = decode_named(&bytes).expect("deserializes");
695            assert_eq!(back.expect, expect);
696            assert_eq!(back.key, b"hits");
697        }
698    }
699
700    #[test]
701    fn given_a_committed_reply_when_round_tripped_then_should_preserve_the_version() {
702        let reply = KvReply::Ok(KvOutcome::Committed { version: 9 });
703        let bytes = encode_named(&reply).expect("serializes");
704        let back: KvReply = decode_named(&bytes).expect("deserializes");
705        match back {
706            KvReply::Ok(KvOutcome::Committed { version }) => assert_eq!(version, 9),
707            other => panic!("expected Committed, got {other:?}"),
708        }
709    }
710
711    #[test]
712    fn given_an_exists_metadata_reply_when_round_tripped_then_should_preserve_metadata() {
713        let reply = KvReply::Ok(KvOutcome::Metadata(Some(KvMetadata {
714            version: 4,
715            expires_at_micros: Some(1_700_000_000_000_000),
716            size_bytes: 128,
717        })));
718        let bytes = encode_named(&reply).expect("serializes");
719        let back: KvReply = decode_named(&bytes).expect("deserializes");
720        let KvReply::Ok(KvOutcome::Metadata(Some(meta))) = back else {
721            panic!("expected Ok(Metadata(Some))");
722        };
723        assert_eq!(meta.version, 4);
724        assert_eq!(meta.size_bytes, 128);
725    }
726
727    #[test]
728    fn given_a_patch_request_when_round_tripped_then_should_preserve_patch_and_precondition() {
729        let request = KvPatch {
730            v: KV_OP_VERSION,
731            namespace: "docs".to_owned(),
732            key: b"doc:1".to_vec(),
733            patch: br#"{"status":"closed"}"#.to_vec(),
734            if_match: Some(3),
735        };
736        let bytes = encode_named(&request).expect("serializes");
737        let back: KvPatch = decode_named(&bytes).expect("deserializes");
738        assert_eq!(back.patch, br#"{"status":"closed"}"#);
739        assert_eq!(back.if_match, Some(3));
740    }
741
742    #[test]
743    fn given_a_lease_reply_when_round_tripped_then_should_preserve_token_ttl_and_position() {
744        let position = MutationPosition {
745            topic_generation: 3,
746            partition: 1,
747            offset: 4_200,
748        };
749        for reply in [
750            KvReply::Ok(KvOutcome::Leased {
751                lease_token: 77,
752                granted_ttl_micros: 30_000_000,
753                position,
754            }),
755            KvReply::Ok(KvOutcome::Renewed {
756                lease_token: 77,
757                granted_ttl_micros: 30_000_000,
758                position,
759            }),
760        ] {
761            let bytes = encode_named(&reply).expect("serializes");
762            let back: KvReply = decode_named(&bytes).expect("deserializes");
763            match back {
764                KvReply::Ok(
765                    KvOutcome::Leased {
766                        lease_token,
767                        granted_ttl_micros,
768                        position: got,
769                    }
770                    | KvOutcome::Renewed {
771                        lease_token,
772                        granted_ttl_micros,
773                        position: got,
774                    },
775                ) => {
776                    assert_eq!(lease_token, 77);
777                    assert_eq!(granted_ttl_micros, 30_000_000);
778                    assert_eq!(got, position);
779                }
780                other => panic!("expected Leased or Renewed, got {other:?}"),
781            }
782        }
783    }
784
785    #[test]
786    fn given_a_reshaped_lease_request_when_round_tripped_then_should_preserve_holder_and_subject() {
787        let request = KvLease {
788            v: crate::codes::KV_LEASE_OP_VERSION,
789            namespace: "connectors.coordination".to_owned(),
790            key: b"source-owner".to_vec(),
791            lease_ttl_micros: 30_000_000,
792            holder_id: "warden-node-1".to_owned(),
793            subject_user_id: Some(42),
794        };
795        let bytes = encode_named(&request).expect("serializes");
796        let back: KvLease = decode_named(&bytes).expect("deserializes");
797        assert_eq!(back.holder_id, "warden-node-1");
798        assert_eq!(back.subject_user_id, Some(42));
799        // A self-lease omits the subject on the wire.
800        let own = KvLease {
801            subject_user_id: None,
802            ..request
803        };
804        let json = serde_json::to_string(&own).expect("json");
805        assert!(!json.contains("subject_user_id"), "absent subject omitted");
806    }
807
808    #[test]
809    fn given_a_renew_request_when_round_tripped_then_should_preserve_token_and_holder() {
810        let request = KvLeaseRenew {
811            v: crate::codes::KV_LEASE_OP_VERSION,
812            namespace: "connectors.coordination".to_owned(),
813            key: b"source-owner".to_vec(),
814            holder_id: "warden-node-1".to_owned(),
815            subject_user_id: None,
816            lease_token: 7,
817            lease_ttl_micros: 30_000_000,
818        };
819        let bytes = encode_named(&request).expect("serializes");
820        let back: KvLeaseRenew = decode_named(&bytes).expect("deserializes");
821        assert_eq!(back.lease_token, 7);
822        assert_eq!(back.holder_id, "warden-node-1");
823    }
824
825    #[test]
826    fn given_a_pre_fencing_lease_payload_when_decoded_then_should_fail_closed() {
827        // The v1 shapes, re-declared locally: no holder identity on lease and
828        // release, no coordination namespace on the fenced CAS. The reshape
829        // made those fields required, so an old payload must fail decode
830        // rather than fill in defaults.
831        #[derive(Serialize)]
832        struct LegacyLease<'a> {
833            v: u32,
834            namespace: &'a str,
835            #[serde(with = "crate::encoding::bin_bytes")]
836            key: &'a [u8],
837            lease_ttl_micros: u64,
838        }
839        let legacy = encode_named(&LegacyLease {
840            v: KV_OP_VERSION,
841            namespace: "sessions",
842            key: b"job",
843            lease_ttl_micros: 30_000_000,
844        })
845        .expect("legacy shape serializes");
846        assert!(
847            decode_named::<KvLease>(&legacy).is_err(),
848            "a holder-less lease payload must not decode"
849        );
850
851        #[derive(Serialize)]
852        struct LegacyRelease<'a> {
853            v: u32,
854            namespace: &'a str,
855            #[serde(with = "crate::encoding::bin_bytes")]
856            key: &'a [u8],
857            lease_token: u64,
858        }
859        let legacy = encode_named(&LegacyRelease {
860            v: KV_OP_VERSION,
861            namespace: "sessions",
862            key: b"job",
863            lease_token: 7,
864        })
865        .expect("legacy shape serializes");
866        assert!(
867            decode_named::<KvRelease>(&legacy).is_err(),
868            "a holder-less release payload must not decode"
869        );
870
871        #[derive(Serialize)]
872        struct LegacyCasFenced<'a> {
873            v: u32,
874            namespace: &'a str,
875            #[serde(with = "crate::encoding::bin_bytes")]
876            key: &'a [u8],
877            #[serde(with = "crate::encoding::bin_bytes")]
878            value: &'a [u8],
879            expect: CasExpect,
880            #[serde(with = "crate::encoding::bin_bytes")]
881            fence_key: &'a [u8],
882            fence_token: u64,
883        }
884        let legacy = encode_named(&LegacyCasFenced {
885            v: KV_OP_VERSION,
886            namespace: "sessions",
887            key: b"cursor",
888            value: b"42",
889            expect: CasExpect::Absent,
890            fence_key: b"job",
891            fence_token: 7,
892        })
893        .expect("legacy shape serializes");
894        assert!(
895            decode_named::<KvCasFenced>(&legacy).is_err(),
896            "a fence-only CAS payload must not decode"
897        );
898    }
899
900    #[test]
901    fn given_a_stale_reply_when_round_tripped_then_should_preserve_the_required_position() {
902        let required = MutationPosition {
903            topic_generation: 2,
904            partition: 0,
905            offset: 99,
906        };
907        let reply = KvReply::Err(KvError::Stale { required });
908        let bytes = encode_named(&reply).expect("serializes");
909        let back: KvReply = decode_named(&bytes).expect("deserializes");
910        match back {
911            KvReply::Err(KvError::Stale { required: got }) => assert_eq!(got, required),
912            other => panic!("expected Stale, got {other:?}"),
913        }
914    }
915
916    #[test]
917    fn given_a_conditional_get_when_round_tripped_then_should_preserve_if_none_match() {
918        let request = KvGet {
919            v: KV_OP_VERSION,
920            namespace: "sessions".to_owned(),
921            key: b"user:1".to_vec(),
922            if_none_match: Some(5),
923            min_position: None,
924        };
925        let bytes = encode_named(&request).expect("serializes");
926        let back: KvGet = decode_named(&bytes).expect("deserializes");
927        assert_eq!(back.if_none_match, Some(5));
928        // A plain get omits the precondition and the barrier on the wire, so
929        // the pre-conditional contract stays byte-identical.
930        let plain = KvGet {
931            if_none_match: None,
932            ..request
933        };
934        let json = serde_json::to_string(&plain).expect("json");
935        assert!(
936            !json.contains("if_none_match"),
937            "absent precondition omitted"
938        );
939        assert!(!json.contains("min_position"), "absent barrier omitted");
940    }
941
942    #[test]
943    fn given_a_barriered_get_when_round_tripped_then_should_preserve_the_minimum_position() {
944        let request = KvGet {
945            v: KV_OP_VERSION,
946            namespace: "connectors.state".to_owned(),
947            key: b"source_state/pg".to_vec(),
948            if_none_match: None,
949            min_position: Some(MutationPosition {
950                topic_generation: 1,
951                partition: 0,
952                offset: 512,
953            }),
954        };
955        let bytes = encode_named(&request).expect("serializes");
956        let back: KvGet = decode_named(&bytes).expect("deserializes");
957        assert_eq!(
958            back.min_position,
959            Some(MutationPosition {
960                topic_generation: 1,
961                partition: 0,
962                offset: 512,
963            })
964        );
965    }
966
967    #[test]
968    fn given_a_version_conflict_when_round_tripped_then_should_preserve_the_current_version() {
969        for current in [Some(3u64), None] {
970            let reply = KvReply::Err(KvError::VersionConflict { current });
971            let bytes = encode_named(&reply).expect("serializes");
972            let back: KvReply = decode_named(&bytes).expect("deserializes");
973            match back {
974                KvReply::Err(KvError::VersionConflict { current: got }) => assert_eq!(got, current),
975                other => panic!("expected VersionConflict, got {other:?}"),
976            }
977        }
978    }
979
980    #[test]
981    fn given_a_versioned_entry_when_round_tripped_then_should_preserve_version_and_skip_zero() {
982        let entry = KvEntry {
983            key: b"k".to_vec(),
984            value: b"v".to_vec(),
985            expires_at_micros: None,
986            version: 5,
987            scope: None,
988            source: None,
989        };
990        let bytes = encode_named(&entry).expect("serializes");
991        let back: KvEntry = decode_named(&bytes).expect("deserializes");
992        assert_eq!(back.version, 5);
993        // An unversioned entry (version 0) omits the field on the wire, so a
994        // pre-versioning store stays byte-identical.
995        let unversioned = KvEntry {
996            version: 0,
997            ..entry
998        };
999        let json = serde_json::to_string(&unversioned).expect("json");
1000        assert!(
1001            !json.contains("version"),
1002            "version 0 must be omitted: {json}"
1003        );
1004    }
1005
1006    #[test]
1007    fn given_a_scan_with_bounds_when_round_tripped_then_should_preserve_filters() {
1008        let scan = KvScan {
1009            v: KV_OP_VERSION,
1010            namespace: "sessions".to_owned(),
1011            prefix: Some(b"user:".to_vec()),
1012            start: None,
1013            end: None,
1014            key_contains: Some("admin".to_owned()),
1015            conversation: None,
1016            limit: 50,
1017            cursor: Some(b"user:9".to_vec()),
1018        };
1019        let bytes = encode_named(&scan).expect("serializes");
1020        let back: KvScan = decode_named(&bytes).expect("deserializes");
1021        assert_eq!(back.prefix.as_deref(), Some(b"user:".as_ref()));
1022        assert_eq!(back.key_contains.as_deref(), Some("admin"));
1023        assert_eq!(back.cursor.as_deref(), Some(b"user:9".as_ref()));
1024    }
1025}