Skip to main content

laser_wire/
validate.rs

1use crate::batch::BatchRequest;
2use crate::clients::ClientMetadata;
3use crate::codes::AGDX_BATCH_CODE;
4use crate::codes::KV_LEASE_OP_VERSION;
5use crate::error::InvalidError;
6use crate::graph::{GraphEdge, GraphNode, GraphQuery, GraphUpsert, SourceRef};
7use crate::kv::{KvCasFenced, KvLease, KvLeaseRenew, KvRelease, KvScan, KvSet};
8use crate::limits::{
9    MAX_BATCH_OPS, MAX_CLIENT_METADATA, MAX_FRAME_BYTES, MAX_GRAPH_NODE_LABELS,
10    MAX_GRAPH_RESULT_ELEMENTS, MAX_GRAPH_TRAVERSE_DEPTH, MAX_HOLDER_ID_BYTES, MAX_KEY_BYTES,
11    MAX_LEASE_TTL_MICROS, MAX_MEMORY_BODY_BYTES, MAX_METADATA_ENTRIES, MAX_METADATA_KEY_BYTES,
12    MAX_PAGE_SIZE, MAX_QUERY_NAME_BYTES, MAX_SCAN_LIMIT, MAX_SOURCE_REF_BYTES,
13    MAX_TEXT_QUERY_BYTES, MAX_VALUE_BYTES, MAX_VECTOR_DIMENSIONS, MIN_LEASE_TTL_MICROS,
14};
15use crate::memory::MemoryRecord;
16use crate::query::{TextQuery, Value, VectorQuery};
17
18/// A capped request type that enforces its own size and shape limits, so the
19/// cap logic lives once in the wire crate and every port and both servers get
20/// the identical check by construction rather than each remembering to compare
21/// against [`crate::limits`]. The SDK calls it before encoding, the servers call
22/// it after decode and before execution.
23pub trait Validate {
24    /// Reject a request that violates a pinned cap or a structural rule.
25    fn validate(&self) -> Result<(), InvalidError>;
26}
27
28/// The shared rule for caller-chosen names that flow into matching, filtering,
29/// or storage identifiers: non-empty, within `cap` bytes, and made only of
30/// ASCII letters, digits, `-`, `_`, and `.`. A strict safelist, not just a
31/// length bound, because these names get inlined into queries, filters, and
32/// rendered views.
33pub(crate) fn validate_safelisted_name(
34    label: &str,
35    value: &str,
36    cap: usize,
37) -> Result<(), InvalidError> {
38    if value.is_empty() {
39        return Err(InvalidError::new(format!("{label} must not be empty")));
40    }
41    if value.len() > cap {
42        return Err(InvalidError::new(format!(
43            "{label} is {}B, exceeds cap {cap}B",
44            value.len()
45        )));
46    }
47    if let Some(bad) = value
48        .bytes()
49        .find(|byte| !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
50    {
51        return Err(InvalidError::new(format!(
52            "{label} has a disallowed byte {bad:#04x}: allowed are ASCII letters, digits, '-', '_', '.'"
53        )));
54    }
55    Ok(())
56}
57
58impl Validate for BatchRequest {
59    fn validate(&self) -> Result<(), InvalidError> {
60        if self.ops.len() > MAX_BATCH_OPS {
61            return Err(InvalidError::new(format!(
62                "batch has {} ops, exceeds cap {MAX_BATCH_OPS}",
63                self.ops.len()
64            )));
65        }
66        let mut total = 0usize;
67        for item in &self.ops {
68            // A batch never nests a batch: an op that is itself a batch would let
69            // one request fan out without bound past the op cap.
70            if item.code == AGDX_BATCH_CODE {
71                return Err(InvalidError::new(
72                    "a batch may not contain a batch op".to_owned(),
73                ));
74            }
75            if item.payload.len() > MAX_VALUE_BYTES {
76                return Err(InvalidError::new(format!(
77                    "batch op payload is {}B, exceeds cap {MAX_VALUE_BYTES}B",
78                    item.payload.len()
79                )));
80            }
81            total = total.saturating_add(item.payload.len());
82        }
83        if total > MAX_FRAME_BYTES {
84            return Err(InvalidError::new(format!(
85                "batch total payload is {total}B, exceeds cap {MAX_FRAME_BYTES}B"
86            )));
87        }
88        Ok(())
89    }
90}
91
92impl Validate for KvSet {
93    fn validate(&self) -> Result<(), InvalidError> {
94        crate::kv::validate_namespace(&self.namespace)?;
95        if self.key.is_empty() {
96            return Err(InvalidError::new("key-value key is empty".to_owned()));
97        }
98        if self.key.len() > MAX_KEY_BYTES {
99            return Err(InvalidError::new(format!(
100                "key is {}B, exceeds cap {MAX_KEY_BYTES}B",
101                self.key.len()
102            )));
103        }
104        if self.value.len() > MAX_VALUE_BYTES {
105            return Err(InvalidError::new(format!(
106                "value is {}B, exceeds cap {MAX_VALUE_BYTES}B",
107                self.value.len()
108            )));
109        }
110        Ok(())
111    }
112}
113
114impl Validate for KvScan {
115    fn validate(&self) -> Result<(), InvalidError> {
116        crate::kv::validate_namespace(&self.namespace)?;
117        if self.limit > MAX_SCAN_LIMIT {
118            return Err(InvalidError::new(format!(
119                "scan limit {} exceeds cap {MAX_SCAN_LIMIT}",
120                self.limit
121            )));
122        }
123        Ok(())
124    }
125}
126
127// The fenced-lease family shares its structural rules, so they live once: the
128// exact op version (any other `v` is a typed rejection, the fail-closed half
129// of the in-place reshape), a bounded key, a bounded non-empty holder.
130fn validate_lease_shape(
131    v: u32,
132    namespace: &str,
133    key: &[u8],
134    holder_id: &str,
135) -> Result<(), InvalidError> {
136    if v != KV_LEASE_OP_VERSION {
137        return Err(InvalidError::new(format!(
138            "unsupported fenced-lease op version (expected {KV_LEASE_OP_VERSION}, got {v})"
139        )));
140    }
141    crate::kv::validate_namespace(namespace)?;
142    validate_kv_key("lease key", key)?;
143    if holder_id.is_empty() {
144        return Err(InvalidError::new("lease holder id must not be empty"));
145    }
146    if holder_id.len() > MAX_HOLDER_ID_BYTES {
147        return Err(InvalidError::new(format!(
148            "lease holder id is {}B, exceeds cap {MAX_HOLDER_ID_BYTES}B",
149            holder_id.len()
150        )));
151    }
152    Ok(())
153}
154
155// A requested lease lifetime is bounded on both sides, and the bound is the
156// contract's, not each backend's: a floor so a grant survives one round trip
157// plus the holder's renewal cadence, a ceiling so a crashed holder's ownership
158// always expires. The store may still grant less than the request
159// (`granted_ttl_micros`), so this rejects only what no deployment would serve,
160// and it rejects it locally instead of after a round trip.
161fn validate_lease_ttl(ttl_micros: u64) -> Result<(), InvalidError> {
162    if !(MIN_LEASE_TTL_MICROS..=MAX_LEASE_TTL_MICROS).contains(&ttl_micros) {
163        return Err(InvalidError::new(format!(
164            "lease ttl is {ttl_micros}\u{b5}s, outside the supported range \
165             {MIN_LEASE_TTL_MICROS}..={MAX_LEASE_TTL_MICROS}\u{b5}s"
166        )));
167    }
168    Ok(())
169}
170
171fn validate_kv_key(label: &str, key: &[u8]) -> Result<(), InvalidError> {
172    if key.is_empty() {
173        return Err(InvalidError::new(format!("{label} is empty")));
174    }
175    if key.len() > MAX_KEY_BYTES {
176        return Err(InvalidError::new(format!(
177            "{label} is {}B, exceeds cap {MAX_KEY_BYTES}B",
178            key.len()
179        )));
180    }
181    Ok(())
182}
183
184impl Validate for KvLease {
185    fn validate(&self) -> Result<(), InvalidError> {
186        validate_lease_shape(self.v, &self.namespace, &self.key, &self.holder_id)?;
187        validate_lease_ttl(self.lease_ttl_micros)?;
188        Ok(())
189    }
190}
191
192impl Validate for KvLeaseRenew {
193    fn validate(&self) -> Result<(), InvalidError> {
194        validate_lease_shape(self.v, &self.namespace, &self.key, &self.holder_id)?;
195        validate_lease_ttl(self.lease_ttl_micros)?;
196        if self.lease_token == 0 {
197            return Err(InvalidError::new("lease token must not be zero"));
198        }
199        Ok(())
200    }
201}
202
203impl Validate for KvRelease {
204    fn validate(&self) -> Result<(), InvalidError> {
205        validate_lease_shape(self.v, &self.namespace, &self.key, &self.holder_id)?;
206        if self.lease_token == 0 {
207            return Err(InvalidError::new("lease token must not be zero"));
208        }
209        Ok(())
210    }
211}
212
213impl Validate for KvCasFenced {
214    fn validate(&self) -> Result<(), InvalidError> {
215        if self.v != KV_LEASE_OP_VERSION {
216            return Err(InvalidError::new(format!(
217                "unsupported fenced-lease op version (expected {KV_LEASE_OP_VERSION}, got {})",
218                self.v
219            )));
220        }
221        crate::kv::validate_namespace(&self.namespace)?;
222        crate::kv::validate_namespace(&self.fence_namespace)?;
223        validate_kv_key("key-value key", &self.key)?;
224        validate_kv_key("fence key", &self.fence_key)?;
225        if self.value.len() > MAX_VALUE_BYTES {
226            return Err(InvalidError::new(format!(
227                "value is {}B, exceeds cap {MAX_VALUE_BYTES}B",
228                self.value.len()
229            )));
230        }
231        // Zero is the reserved "unversioned" sentinel, never a granted token.
232        if self.fence_token == 0 {
233            return Err(InvalidError::new("fence token must not be zero"));
234        }
235        Ok(())
236    }
237}
238
239impl Validate for GraphQuery {
240    fn validate(&self) -> Result<(), InvalidError> {
241        crate::graph::validate_graph_name(&self.graph)?;
242        if self.traverse.len() > MAX_GRAPH_TRAVERSE_DEPTH as usize {
243            return Err(InvalidError::new(format!(
244                "graph traversal depth {} exceeds cap {MAX_GRAPH_TRAVERSE_DEPTH}",
245                self.traverse.len()
246            )));
247        }
248        if self.limit > MAX_GRAPH_RESULT_ELEMENTS {
249            return Err(InvalidError::new(format!(
250                "graph result limit {} exceeds cap {MAX_GRAPH_RESULT_ELEMENTS}",
251                self.limit
252            )));
253        }
254        Ok(())
255    }
256}
257
258impl Validate for SourceRef {
259    fn validate(&self) -> Result<(), InvalidError> {
260        let size = match self {
261            // A log pointer is fixed-width numerics plus an optional
262            // conversation id, so only the id needs a bound.
263            SourceRef::Message { conversation, .. } => conversation.as_ref().map_or(0, String::len),
264            SourceRef::Kv { namespace, key } => namespace.len() + key.len(),
265            SourceRef::Memory { id } => id.len(),
266        };
267        if size > MAX_SOURCE_REF_BYTES {
268            return Err(InvalidError::new(format!(
269                "source reference is {size}B, exceeds cap {MAX_SOURCE_REF_BYTES}B"
270            )));
271        }
272        Ok(())
273    }
274}
275
276// Attribute lists ride on both nodes and edges, so their bound lives once.
277fn validate_attrs(label: &str, attrs: &[(String, Value)]) -> Result<(), InvalidError> {
278    if attrs.len() > MAX_METADATA_ENTRIES {
279        return Err(InvalidError::new(format!(
280            "{label} carries {} attributes, exceeds cap {MAX_METADATA_ENTRIES}",
281            attrs.len()
282        )));
283    }
284    for (key, _) in attrs {
285        if key.len() > MAX_METADATA_KEY_BYTES {
286            return Err(InvalidError::new(format!(
287                "{label} attribute name is {}B, exceeds cap {MAX_METADATA_KEY_BYTES}B",
288                key.len()
289            )));
290        }
291    }
292    Ok(())
293}
294
295impl Validate for GraphNode {
296    fn validate(&self) -> Result<(), InvalidError> {
297        if self.labels.len() > MAX_GRAPH_NODE_LABELS {
298            return Err(InvalidError::new(format!(
299                "graph node carries {} labels, exceeds cap {MAX_GRAPH_NODE_LABELS}",
300                self.labels.len()
301            )));
302        }
303        validate_attrs("graph node", &self.attrs)?;
304        if let Some(source) = &self.source {
305            source.validate()?;
306        }
307        Ok(())
308    }
309}
310
311impl Validate for GraphEdge {
312    fn validate(&self) -> Result<(), InvalidError> {
313        validate_attrs("graph edge", &self.attrs)?;
314        if let Some(source) = &self.source {
315            source.validate()?;
316        }
317        Ok(())
318    }
319}
320
321impl Validate for GraphUpsert {
322    fn validate(&self) -> Result<(), InvalidError> {
323        crate::graph::validate_graph_name(&self.graph)?;
324        let elements = self.nodes.len() + self.edges.len();
325        if elements > MAX_GRAPH_RESULT_ELEMENTS {
326            return Err(InvalidError::new(format!(
327                "graph upsert carries {elements} elements, exceeds cap {MAX_GRAPH_RESULT_ELEMENTS}"
328            )));
329        }
330        for node in &self.nodes {
331            node.validate()?;
332        }
333        for edge in &self.edges {
334            edge.validate()?;
335        }
336        Ok(())
337    }
338}
339
340impl Validate for TextQuery {
341    fn validate(&self) -> Result<(), InvalidError> {
342        if self.query.trim().is_empty() || self.query.len() > MAX_TEXT_QUERY_BYTES {
343            return Err(InvalidError::new(format!(
344                "text query is {}B, expected 1..={MAX_TEXT_QUERY_BYTES}B",
345                self.query.len()
346            )));
347        }
348        if self.query.chars().any(char::is_control) {
349            return Err(InvalidError::new("text query contains a control character"));
350        }
351        if let Some(field) = &self.field
352            && (field.is_empty()
353                || field.len() > MAX_QUERY_NAME_BYTES
354                || field.chars().any(char::is_control))
355        {
356            return Err(InvalidError::new("text query field is invalid"));
357        }
358        Ok(())
359    }
360}
361
362impl Validate for VectorQuery {
363    fn validate(&self) -> Result<(), InvalidError> {
364        if self.field.is_empty()
365            || self.field.len() > MAX_QUERY_NAME_BYTES
366            || self.field.chars().any(char::is_control)
367        {
368            return Err(InvalidError::new("vector field is invalid"));
369        }
370        if self.embedding.is_empty() || self.embedding.len() > MAX_VECTOR_DIMENSIONS {
371            return Err(InvalidError::new(format!(
372                "vector dimensions must be in 1..={MAX_VECTOR_DIMENSIONS}"
373            )));
374        }
375        if self
376            .embedding
377            .iter()
378            .any(|value| !value.is_finite() || (*value == 0.0 && value.is_sign_negative()))
379        {
380            return Err(InvalidError::new(
381                "vector values must be finite canonical floats",
382            ));
383        }
384        if self.top_k == 0 || self.top_k > MAX_PAGE_SIZE as u32 {
385            return Err(InvalidError::new(format!(
386                "vector top_k {} is outside 1..={MAX_PAGE_SIZE}",
387                self.top_k
388            )));
389        }
390        Ok(())
391    }
392}
393
394impl Validate for MemoryRecord {
395    fn validate(&self) -> Result<(), InvalidError> {
396        if let MemoryRecord::Item { body, .. } = self
397            && body.len() > MAX_MEMORY_BODY_BYTES
398        {
399            return Err(InvalidError::new(format!(
400                "memory body is {}B, exceeds cap {MAX_MEMORY_BODY_BYTES}B",
401                body.len()
402            )));
403        }
404        Ok(())
405    }
406}
407
408impl Validate for ClientMetadata {
409    fn validate(&self) -> Result<(), InvalidError> {
410        let size = self.metadata.as_ref().map_or(0, Vec::len);
411        if size > MAX_CLIENT_METADATA {
412            return Err(InvalidError::new(format!(
413                "client metadata is {size}B, exceeds cap {MAX_CLIENT_METADATA}B"
414            )));
415        }
416        Ok(())
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::batch::BatchItem;
424    use crate::codes::{AGDX_KV_GET_CODE, BATCH_OP_VERSION, KV_OP_VERSION};
425
426    #[test]
427    fn given_a_batch_over_the_op_cap_when_validated_then_should_reject() {
428        let request = BatchRequest {
429            v: BATCH_OP_VERSION,
430            ops: (0..MAX_BATCH_OPS + 1)
431                .map(|_| BatchItem {
432                    code: AGDX_KV_GET_CODE,
433                    payload: Vec::new(),
434                })
435                .collect(),
436        };
437        assert!(request.validate().is_err());
438    }
439
440    #[test]
441    fn given_a_batch_nesting_a_batch_when_validated_then_should_reject() {
442        let request = BatchRequest {
443            v: BATCH_OP_VERSION,
444            ops: vec![BatchItem {
445                code: AGDX_BATCH_CODE,
446                payload: Vec::new(),
447            }],
448        };
449        assert!(request.validate().is_err());
450    }
451
452    #[test]
453    fn given_namespaces_when_validated_then_should_enforce_bounds() {
454        use crate::kv::validate_namespace;
455        use crate::limits::MAX_NAMESPACE_BYTES;
456        assert!(validate_namespace("default").is_ok());
457        assert!(validate_namespace("agent-abc/session").is_ok(), "hierarchy");
458        assert!(validate_namespace("").is_err(), "empty");
459        assert!(validate_namespace("bad\nns").is_err(), "control byte");
460        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES)).is_ok());
461        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES + 1)).is_err());
462    }
463
464    #[test]
465    fn given_graph_names_when_validated_then_should_enforce_bounds() {
466        use crate::graph::validate_graph_name;
467        use crate::limits::MAX_GRAPH_NAME_BYTES;
468        assert!(validate_graph_name("knowledge").is_ok());
469        assert!(validate_graph_name("").is_err(), "empty");
470        assert!(validate_graph_name("bad\tname").is_err(), "control byte");
471        assert!(validate_graph_name(&"g".repeat(MAX_GRAPH_NAME_BYTES + 1)).is_err());
472    }
473
474    #[test]
475    fn given_a_lease_request_when_validated_then_should_enforce_version_holder_and_ttl() {
476        use crate::codes::KV_LEASE_OP_VERSION;
477        use crate::kv::KvLease;
478        use crate::limits::MAX_HOLDER_ID_BYTES;
479        let valid = KvLease {
480            v: KV_LEASE_OP_VERSION,
481            namespace: "connectors.coordination".to_owned(),
482            key: b"source-owner".to_vec(),
483            lease_ttl_micros: 30_000_000,
484            holder_id: "warden-node-1".to_owned(),
485            subject_user_id: Some(42),
486        };
487        assert!(valid.validate().is_ok());
488        let wrong_version = KvLease {
489            v: KV_LEASE_OP_VERSION + 1,
490            ..valid.clone()
491        };
492        assert!(
493            wrong_version.validate().is_err(),
494            "an unsupported version is rejected typed"
495        );
496        let empty_holder = KvLease {
497            holder_id: String::new(),
498            ..valid.clone()
499        };
500        assert!(empty_holder.validate().is_err());
501        let long_holder = KvLease {
502            holder_id: "h".repeat(MAX_HOLDER_ID_BYTES + 1),
503            ..valid.clone()
504        };
505        assert!(long_holder.validate().is_err());
506        let zero_ttl = KvLease {
507            lease_ttl_micros: 0,
508            ..valid
509        };
510        assert!(zero_ttl.validate().is_err());
511    }
512
513    // The TTL range is the contract's, so the same rejection happens on the
514    // acquire and the renewal, before any round trip, on every tier.
515    #[test]
516    fn given_out_of_range_lease_ttls_when_validated_then_should_reject_both_ends() {
517        use crate::codes::KV_LEASE_OP_VERSION;
518        use crate::kv::{KvLease, KvLeaseRenew};
519        use crate::limits::{MAX_LEASE_TTL_MICROS, MIN_LEASE_TTL_MICROS};
520        let acquire = KvLease {
521            v: KV_LEASE_OP_VERSION,
522            namespace: "connectors.coordination".to_owned(),
523            key: b"source-owner".to_vec(),
524            lease_ttl_micros: MIN_LEASE_TTL_MICROS,
525            holder_id: "warden-node-1".to_owned(),
526            subject_user_id: None,
527        };
528        assert!(acquire.validate().is_ok(), "the floor itself is grantable");
529        assert!(
530            KvLease {
531                lease_ttl_micros: MAX_LEASE_TTL_MICROS,
532                ..acquire.clone()
533            }
534            .validate()
535            .is_ok(),
536            "the ceiling itself is grantable"
537        );
538        assert!(
539            KvLease {
540                lease_ttl_micros: MIN_LEASE_TTL_MICROS - 1,
541                ..acquire.clone()
542            }
543            .validate()
544            .is_err(),
545            "a lease too short to renew is rejected locally"
546        );
547        assert!(
548            KvLease {
549                lease_ttl_micros: MAX_LEASE_TTL_MICROS + 1,
550                ..acquire
551            }
552            .validate()
553            .is_err(),
554            "an unbounded ownership window is rejected locally"
555        );
556        let renew = KvLeaseRenew {
557            v: KV_LEASE_OP_VERSION,
558            namespace: "connectors.coordination".to_owned(),
559            key: b"source-owner".to_vec(),
560            holder_id: "warden-node-1".to_owned(),
561            subject_user_id: None,
562            lease_token: 7,
563            lease_ttl_micros: MAX_LEASE_TTL_MICROS + 1,
564        };
565        assert!(renew.validate().is_err(), "renewal shares the same range");
566        assert!(
567            KvLeaseRenew {
568                lease_ttl_micros: MIN_LEASE_TTL_MICROS - 1,
569                ..renew
570            }
571            .validate()
572            .is_err()
573        );
574    }
575
576    #[test]
577    fn given_renew_and_release_requests_when_validated_then_should_require_a_nonzero_token() {
578        use crate::codes::KV_LEASE_OP_VERSION;
579        use crate::kv::{KvLeaseRenew, KvRelease};
580        let renew = KvLeaseRenew {
581            v: KV_LEASE_OP_VERSION,
582            namespace: "connectors.coordination".to_owned(),
583            key: b"source-owner".to_vec(),
584            holder_id: "warden-node-1".to_owned(),
585            subject_user_id: None,
586            lease_token: 7,
587            lease_ttl_micros: 30_000_000,
588        };
589        assert!(renew.validate().is_ok());
590        let zero = KvLeaseRenew {
591            lease_token: 0,
592            ..renew
593        };
594        assert!(zero.validate().is_err());
595        let release = KvRelease {
596            v: KV_LEASE_OP_VERSION,
597            namespace: "connectors.coordination".to_owned(),
598            key: b"source-owner".to_vec(),
599            lease_token: 7,
600            holder_id: "warden-node-1".to_owned(),
601        };
602        assert!(release.validate().is_ok());
603        let zero = KvRelease {
604            lease_token: 0,
605            ..release
606        };
607        assert!(zero.validate().is_err());
608    }
609
610    #[test]
611    fn given_a_fenced_cas_when_validated_then_should_enforce_both_namespaces_and_the_token() {
612        use crate::codes::KV_LEASE_OP_VERSION;
613        use crate::kv::{CasExpect, KvCasFenced};
614        let valid = KvCasFenced {
615            v: KV_LEASE_OP_VERSION,
616            namespace: "connectors.state".to_owned(),
617            key: b"source_state/pg".to_vec(),
618            value: b"cursor".to_vec(),
619            expires_at_micros: None,
620            expect: CasExpect::Absent,
621            fence_namespace: "connectors.coordination".to_owned(),
622            fence_key: b"source-owner".to_vec(),
623            fence_token: 8,
624        };
625        assert!(valid.validate().is_ok());
626        let wrong_version = KvCasFenced {
627            v: KV_LEASE_OP_VERSION + 1,
628            ..valid.clone()
629        };
630        assert!(
631            wrong_version.validate().is_err(),
632            "an unsupported version is rejected typed"
633        );
634        let bad_fence_namespace = KvCasFenced {
635            fence_namespace: "bad\nns".to_owned(),
636            ..valid.clone()
637        };
638        assert!(bad_fence_namespace.validate().is_err());
639        let zero_token = KvCasFenced {
640            fence_token: 0,
641            ..valid
642        };
643        assert!(zero_token.validate().is_err());
644    }
645
646    #[test]
647    fn given_an_oversized_key_when_validated_then_should_reject_and_a_valid_one_passes() {
648        let over = KvSet {
649            v: KV_OP_VERSION,
650            namespace: "ns".to_owned(),
651            key: vec![b'x'; MAX_KEY_BYTES + 1],
652            value: vec![1, 2, 3],
653            expires_at_micros: None,
654        };
655        assert!(over.validate().is_err());
656        let ok = KvSet {
657            v: KV_OP_VERSION,
658            namespace: "ns".to_owned(),
659            key: vec![b'x'; 8],
660            value: vec![1, 2, 3],
661            expires_at_micros: None,
662        };
663        assert!(ok.validate().is_ok());
664    }
665
666    fn node_with(labels: Vec<String>, attrs: Vec<(String, Value)>) -> GraphNode {
667        GraphNode {
668            id: crate::graph::NodeId::from_u128(1),
669            labels,
670            attrs,
671            embedding: None,
672            source: None,
673        }
674    }
675
676    #[test]
677    fn given_a_node_over_the_label_cap_when_validated_then_should_reject() {
678        let over = node_with(
679            (0..=MAX_GRAPH_NODE_LABELS)
680                .map(|index| format!("label-{index}"))
681                .collect(),
682            Vec::new(),
683        );
684        assert!(over.validate().is_err());
685        assert!(
686            node_with(vec!["Person".to_owned()], Vec::new())
687                .validate()
688                .is_ok()
689        );
690    }
691
692    #[test]
693    fn given_a_node_over_the_attribute_cap_when_validated_then_should_reject() {
694        let over = node_with(
695            Vec::new(),
696            (0..=MAX_METADATA_ENTRIES)
697                .map(|index| (format!("attr-{index}"), Value::from("x")))
698                .collect(),
699        );
700        assert!(over.validate().is_err());
701    }
702
703    #[test]
704    fn given_an_oversized_source_reference_when_validated_then_should_reject() {
705        let over = SourceRef::Kv {
706            namespace: "n".repeat(MAX_SOURCE_REF_BYTES),
707            key: "k".repeat(MAX_SOURCE_REF_BYTES),
708        };
709        assert!(over.validate().is_err());
710        assert!(
711            SourceRef::Memory {
712                id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned()
713            }
714            .validate()
715            .is_ok()
716        );
717    }
718
719    #[test]
720    fn given_an_upsert_over_the_element_cap_when_validated_then_should_reject() {
721        let over = GraphUpsert {
722            v: 1,
723            graph: "knowledge".to_owned(),
724            nodes: (0..=MAX_GRAPH_RESULT_ELEMENTS)
725                .map(|_| node_with(Vec::new(), Vec::new()))
726                .collect(),
727            edges: Vec::new(),
728        };
729        assert!(over.validate().is_err());
730        let ok = GraphUpsert {
731            v: 1,
732            graph: "knowledge".to_owned(),
733            nodes: vec![node_with(vec!["Person".to_owned()], Vec::new())],
734            edges: Vec::new(),
735        };
736        assert!(ok.validate().is_ok());
737    }
738
739    #[test]
740    fn given_an_upsert_carrying_an_over_cap_node_when_validated_then_should_reject() {
741        let upsert = GraphUpsert {
742            v: 1,
743            graph: "knowledge".to_owned(),
744            nodes: vec![node_with(
745                (0..=MAX_GRAPH_NODE_LABELS)
746                    .map(|index| format!("label-{index}"))
747                    .collect(),
748                Vec::new(),
749            )],
750            edges: Vec::new(),
751        };
752        assert!(
753            upsert.validate().is_err(),
754            "an upsert must enforce its elements' caps, not just its own"
755        );
756    }
757
758    #[test]
759    fn given_an_oversized_text_query_when_validated_then_should_reject() {
760        let over = TextQuery {
761            field: None,
762            query: "q".repeat(MAX_TEXT_QUERY_BYTES + 1),
763        };
764        assert!(over.validate().is_err());
765        let ok = TextQuery {
766            field: None,
767            query: "checkout is slow".to_owned(),
768        };
769        assert!(ok.validate().is_ok());
770    }
771
772    #[test]
773    fn given_an_over_cap_vector_top_k_when_validated_then_should_reject() {
774        let over = VectorQuery {
775            field: "embedding".to_owned(),
776            embedding: vec![0.0; 4],
777            top_k: MAX_PAGE_SIZE as u32 + 1,
778        };
779        assert!(over.validate().is_err());
780        let ok = VectorQuery {
781            field: "embedding".to_owned(),
782            embedding: vec![0.0; 4],
783            top_k: 10,
784        };
785        assert!(ok.validate().is_ok());
786    }
787
788    #[test]
789    fn given_an_oversized_memory_body_when_validated_then_should_reject() {
790        let over = MemoryRecord::Item {
791            id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
792            kind: "fact".to_owned(),
793            body: vec![0u8; MAX_MEMORY_BODY_BYTES + 1],
794        };
795        assert!(over.validate().is_err());
796        let ok = MemoryRecord::Item {
797            id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
798            kind: "fact".to_owned(),
799            body: b"checkout is slow".to_vec(),
800        };
801        assert!(ok.validate().is_ok());
802        assert!(
803            MemoryRecord::Forget {
804                target: "01KWM3K3XEP3NP5TN850J17YBP".to_owned()
805            }
806            .validate()
807            .is_ok()
808        );
809    }
810
811    #[test]
812    fn given_oversized_client_metadata_when_validated_then_should_reject() {
813        let over = ClientMetadata {
814            client_id: 1,
815            user_id: None,
816            transport: 1,
817            address: "127.0.0.1:8090".to_owned(),
818            consumer_groups_count: 0,
819            metadata: Some(vec![0u8; MAX_CLIENT_METADATA + 1]),
820        };
821        assert!(over.validate().is_err());
822        let ok = ClientMetadata {
823            client_id: 1,
824            user_id: None,
825            transport: 1,
826            address: "127.0.0.1:8090".to_owned(),
827            consumer_groups_count: 0,
828            metadata: Some(b"card".to_vec()),
829        };
830        assert!(ok.validate().is_ok());
831    }
832}