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