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        if let MemoryRecord::Forget {
405            conversation: Some(conversation),
406            ..
407        }
408        | MemoryRecord::Feedback {
409            conversation: Some(conversation),
410            ..
411        } = self
412        {
413            conversation
414                .parse::<crate::agent::ConversationId>()
415                .map_err(|_| InvalidError::new("memory record conversation is not a valid id"))?;
416        }
417        Ok(())
418    }
419}
420
421impl Validate for ClientMetadata {
422    fn validate(&self) -> Result<(), InvalidError> {
423        let size = self.metadata.as_ref().map_or(0, Vec::len);
424        if size > MAX_CLIENT_METADATA {
425            return Err(InvalidError::new(format!(
426                "client metadata is {size}B, exceeds cap {MAX_CLIENT_METADATA}B"
427            )));
428        }
429        Ok(())
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::batch::BatchItem;
437    use crate::codes::{AGDX_KV_GET_CODE, BATCH_OP_VERSION, KV_OP_VERSION};
438
439    #[test]
440    fn given_a_batch_over_the_op_cap_when_validated_then_should_reject() {
441        let request = BatchRequest {
442            v: BATCH_OP_VERSION,
443            ops: (0..MAX_BATCH_OPS + 1)
444                .map(|_| BatchItem {
445                    code: AGDX_KV_GET_CODE,
446                    payload: Vec::new(),
447                })
448                .collect(),
449        };
450        assert!(request.validate().is_err());
451    }
452
453    #[test]
454    fn given_a_batch_nesting_a_batch_when_validated_then_should_reject() {
455        let request = BatchRequest {
456            v: BATCH_OP_VERSION,
457            ops: vec![BatchItem {
458                code: AGDX_BATCH_CODE,
459                payload: Vec::new(),
460            }],
461        };
462        assert!(request.validate().is_err());
463    }
464
465    #[test]
466    fn given_namespaces_when_validated_then_should_enforce_bounds() {
467        use crate::kv::validate_namespace;
468        use crate::limits::MAX_NAMESPACE_BYTES;
469        assert!(validate_namespace("default").is_ok());
470        assert!(validate_namespace("agent-abc/session").is_ok(), "hierarchy");
471        assert!(validate_namespace("").is_err(), "empty");
472        assert!(validate_namespace("bad\nns").is_err(), "control byte");
473        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES)).is_ok());
474        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES + 1)).is_err());
475    }
476
477    #[test]
478    fn given_graph_names_when_validated_then_should_enforce_bounds() {
479        use crate::graph::validate_graph_name;
480        use crate::limits::MAX_GRAPH_NAME_BYTES;
481        assert!(validate_graph_name("knowledge").is_ok());
482        assert!(validate_graph_name("").is_err(), "empty");
483        assert!(validate_graph_name("bad\tname").is_err(), "control byte");
484        assert!(validate_graph_name(&"g".repeat(MAX_GRAPH_NAME_BYTES + 1)).is_err());
485    }
486
487    #[test]
488    fn given_a_lease_request_when_validated_then_should_enforce_version_holder_and_ttl() {
489        use crate::codes::KV_LEASE_OP_VERSION;
490        use crate::kv::KvLease;
491        use crate::limits::MAX_HOLDER_ID_BYTES;
492        let valid = KvLease {
493            v: KV_LEASE_OP_VERSION,
494            namespace: "connectors.coordination".to_owned(),
495            key: b"source-owner".to_vec(),
496            lease_ttl_micros: 30_000_000,
497            holder_id: "node-1".to_owned(),
498            subject_user_id: Some(42),
499        };
500        assert!(valid.validate().is_ok());
501        let wrong_version = KvLease {
502            v: KV_LEASE_OP_VERSION + 1,
503            ..valid.clone()
504        };
505        assert!(
506            wrong_version.validate().is_err(),
507            "an unsupported version is rejected typed"
508        );
509        let empty_holder = KvLease {
510            holder_id: String::new(),
511            ..valid.clone()
512        };
513        assert!(empty_holder.validate().is_err());
514        let long_holder = KvLease {
515            holder_id: "h".repeat(MAX_HOLDER_ID_BYTES + 1),
516            ..valid.clone()
517        };
518        assert!(long_holder.validate().is_err());
519        let zero_ttl = KvLease {
520            lease_ttl_micros: 0,
521            ..valid
522        };
523        assert!(zero_ttl.validate().is_err());
524    }
525
526    // The TTL range is the contract's, so the same rejection happens on the
527    // acquire and the renewal, before any round trip, on every tier.
528    #[test]
529    fn given_out_of_range_lease_ttls_when_validated_then_should_reject_both_ends() {
530        use crate::codes::KV_LEASE_OP_VERSION;
531        use crate::kv::{KvLease, KvLeaseRenew};
532        use crate::limits::{MAX_LEASE_TTL_MICROS, MIN_LEASE_TTL_MICROS};
533        let acquire = KvLease {
534            v: KV_LEASE_OP_VERSION,
535            namespace: "connectors.coordination".to_owned(),
536            key: b"source-owner".to_vec(),
537            lease_ttl_micros: MIN_LEASE_TTL_MICROS,
538            holder_id: "node-1".to_owned(),
539            subject_user_id: None,
540        };
541        assert!(acquire.validate().is_ok(), "the floor itself is grantable");
542        assert!(
543            KvLease {
544                lease_ttl_micros: MAX_LEASE_TTL_MICROS,
545                ..acquire.clone()
546            }
547            .validate()
548            .is_ok(),
549            "the ceiling itself is grantable"
550        );
551        assert!(
552            KvLease {
553                lease_ttl_micros: MIN_LEASE_TTL_MICROS - 1,
554                ..acquire.clone()
555            }
556            .validate()
557            .is_err(),
558            "a lease too short to renew is rejected locally"
559        );
560        assert!(
561            KvLease {
562                lease_ttl_micros: MAX_LEASE_TTL_MICROS + 1,
563                ..acquire
564            }
565            .validate()
566            .is_err(),
567            "an unbounded ownership window is rejected locally"
568        );
569        let renew = KvLeaseRenew {
570            v: KV_LEASE_OP_VERSION,
571            namespace: "connectors.coordination".to_owned(),
572            key: b"source-owner".to_vec(),
573            holder_id: "node-1".to_owned(),
574            subject_user_id: None,
575            lease_token: 7,
576            lease_ttl_micros: MAX_LEASE_TTL_MICROS + 1,
577        };
578        assert!(renew.validate().is_err(), "renewal shares the same range");
579        assert!(
580            KvLeaseRenew {
581                lease_ttl_micros: MIN_LEASE_TTL_MICROS - 1,
582                ..renew
583            }
584            .validate()
585            .is_err()
586        );
587    }
588
589    #[test]
590    fn given_renew_and_release_requests_when_validated_then_should_require_a_nonzero_token() {
591        use crate::codes::KV_LEASE_OP_VERSION;
592        use crate::kv::{KvLeaseRenew, KvRelease};
593        let renew = KvLeaseRenew {
594            v: KV_LEASE_OP_VERSION,
595            namespace: "connectors.coordination".to_owned(),
596            key: b"source-owner".to_vec(),
597            holder_id: "node-1".to_owned(),
598            subject_user_id: None,
599            lease_token: 7,
600            lease_ttl_micros: 30_000_000,
601        };
602        assert!(renew.validate().is_ok());
603        let zero = KvLeaseRenew {
604            lease_token: 0,
605            ..renew
606        };
607        assert!(zero.validate().is_err());
608        let release = KvRelease {
609            v: KV_LEASE_OP_VERSION,
610            namespace: "connectors.coordination".to_owned(),
611            key: b"source-owner".to_vec(),
612            lease_token: 7,
613            holder_id: "node-1".to_owned(),
614        };
615        assert!(release.validate().is_ok());
616        let zero = KvRelease {
617            lease_token: 0,
618            ..release
619        };
620        assert!(zero.validate().is_err());
621    }
622
623    #[test]
624    fn given_a_fenced_cas_when_validated_then_should_enforce_both_namespaces_and_the_token() {
625        use crate::codes::KV_LEASE_OP_VERSION;
626        use crate::kv::{CasExpect, KvCasFenced};
627        let valid = KvCasFenced {
628            v: KV_LEASE_OP_VERSION,
629            namespace: "connectors.state".to_owned(),
630            key: b"source_state/pg".to_vec(),
631            value: b"cursor".to_vec(),
632            expires_at_micros: None,
633            expect: CasExpect::Absent,
634            fence_namespace: "connectors.coordination".to_owned(),
635            fence_key: b"source-owner".to_vec(),
636            fence_token: 8,
637        };
638        assert!(valid.validate().is_ok());
639        let wrong_version = KvCasFenced {
640            v: KV_LEASE_OP_VERSION + 1,
641            ..valid.clone()
642        };
643        assert!(
644            wrong_version.validate().is_err(),
645            "an unsupported version is rejected typed"
646        );
647        let bad_fence_namespace = KvCasFenced {
648            fence_namespace: "bad\nns".to_owned(),
649            ..valid.clone()
650        };
651        assert!(bad_fence_namespace.validate().is_err());
652        let zero_token = KvCasFenced {
653            fence_token: 0,
654            ..valid
655        };
656        assert!(zero_token.validate().is_err());
657    }
658
659    #[test]
660    fn given_an_oversized_key_when_validated_then_should_reject_and_a_valid_one_passes() {
661        let over = KvSet {
662            v: KV_OP_VERSION,
663            namespace: "ns".to_owned(),
664            session: None,
665            key: vec![b'x'; MAX_KEY_BYTES + 1],
666            value: vec![1, 2, 3],
667            expires_at_micros: None,
668        };
669        assert!(over.validate().is_err());
670        let ok = KvSet {
671            v: KV_OP_VERSION,
672            namespace: "ns".to_owned(),
673            session: None,
674            key: vec![b'x'; 8],
675            value: vec![1, 2, 3],
676            expires_at_micros: None,
677        };
678        assert!(ok.validate().is_ok());
679    }
680
681    fn node_with(labels: Vec<String>, attrs: Vec<(String, Value)>) -> GraphNode {
682        GraphNode {
683            producer: None,
684            id: crate::graph::NodeId::from_u128(1),
685            labels,
686            attrs,
687            embedding: None,
688            source: None,
689        }
690    }
691
692    #[test]
693    fn given_a_node_over_the_label_cap_when_validated_then_should_reject() {
694        let over = node_with(
695            (0..=MAX_GRAPH_NODE_LABELS)
696                .map(|index| format!("label-{index}"))
697                .collect(),
698            Vec::new(),
699        );
700        assert!(over.validate().is_err());
701        assert!(
702            node_with(vec!["Person".to_owned()], Vec::new())
703                .validate()
704                .is_ok()
705        );
706    }
707
708    #[test]
709    fn given_a_node_over_the_attribute_cap_when_validated_then_should_reject() {
710        let over = node_with(
711            Vec::new(),
712            (0..=MAX_METADATA_ENTRIES)
713                .map(|index| (format!("attr-{index}"), Value::from("x")))
714                .collect(),
715        );
716        assert!(over.validate().is_err());
717    }
718
719    #[test]
720    fn given_an_oversized_source_reference_when_validated_then_should_reject() {
721        let over = SourceRef::Kv {
722            namespace: "n".repeat(MAX_SOURCE_REF_BYTES),
723            key: "k".repeat(MAX_SOURCE_REF_BYTES),
724        };
725        assert!(over.validate().is_err());
726        assert!(
727            SourceRef::Memory {
728                id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned()
729            }
730            .validate()
731            .is_ok()
732        );
733    }
734
735    #[test]
736    fn given_an_upsert_over_the_element_cap_when_validated_then_should_reject() {
737        let over = GraphUpsert {
738            v: 1,
739            graph: "knowledge".to_owned(),
740            session: None,
741            nodes: (0..=MAX_GRAPH_RESULT_ELEMENTS)
742                .map(|_| node_with(Vec::new(), Vec::new()))
743                .collect(),
744            edges: Vec::new(),
745        };
746        assert!(over.validate().is_err());
747        let ok = GraphUpsert {
748            v: 1,
749            graph: "knowledge".to_owned(),
750            session: None,
751            nodes: vec![node_with(vec!["Person".to_owned()], Vec::new())],
752            edges: Vec::new(),
753        };
754        assert!(ok.validate().is_ok());
755    }
756
757    #[test]
758    fn given_an_upsert_carrying_an_over_cap_node_when_validated_then_should_reject() {
759        let upsert = GraphUpsert {
760            v: 1,
761            graph: "knowledge".to_owned(),
762            session: None,
763            nodes: vec![node_with(
764                (0..=MAX_GRAPH_NODE_LABELS)
765                    .map(|index| format!("label-{index}"))
766                    .collect(),
767                Vec::new(),
768            )],
769            edges: Vec::new(),
770        };
771        assert!(
772            upsert.validate().is_err(),
773            "an upsert must enforce its elements' caps, not just its own"
774        );
775    }
776
777    #[test]
778    fn given_an_oversized_text_query_when_validated_then_should_reject() {
779        let over = TextQuery {
780            field: None,
781            query: "q".repeat(MAX_TEXT_QUERY_BYTES + 1),
782        };
783        assert!(over.validate().is_err());
784        let ok = TextQuery {
785            field: None,
786            query: "auth is slow".to_owned(),
787        };
788        assert!(ok.validate().is_ok());
789    }
790
791    #[test]
792    fn given_an_over_cap_vector_top_k_when_validated_then_should_reject() {
793        let over = VectorQuery {
794            field: "embedding".to_owned(),
795            embedding: vec![0.0; 4],
796            top_k: MAX_PAGE_SIZE as u32 + 1,
797        };
798        assert!(over.validate().is_err());
799        let ok = VectorQuery {
800            field: "embedding".to_owned(),
801            embedding: vec![0.0; 4],
802            top_k: 10,
803        };
804        assert!(ok.validate().is_ok());
805    }
806
807    #[test]
808    fn given_an_oversized_memory_body_when_validated_then_should_reject() {
809        let over = MemoryRecord::Item {
810            origin: None,
811            producer: None,
812            id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
813            kind: "fact".to_owned(),
814            body: vec![0u8; MAX_MEMORY_BODY_BYTES + 1],
815        };
816        assert!(over.validate().is_err());
817        let ok = MemoryRecord::Item {
818            origin: None,
819            producer: None,
820            id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
821            kind: "fact".to_owned(),
822            body: b"auth is slow".to_vec(),
823        };
824        assert!(ok.validate().is_ok());
825        assert!(
826            MemoryRecord::Forget {
827                target: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
828                conversation: None,
829            }
830            .validate()
831            .is_ok()
832        );
833        assert!(
834            MemoryRecord::Forget {
835                target: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
836                conversation: Some("not an id".to_owned()),
837            }
838            .validate()
839            .is_err()
840        );
841    }
842
843    #[test]
844    fn given_oversized_client_metadata_when_validated_then_should_reject() {
845        let over = ClientMetadata {
846            client_id: 1,
847            user_id: None,
848            transport: 1,
849            address: "127.0.0.1:8090".to_owned(),
850            consumer_groups_count: 0,
851            metadata: Some(vec![0u8; MAX_CLIENT_METADATA + 1]),
852        };
853        assert!(over.validate().is_err());
854        let ok = ClientMetadata {
855            client_id: 1,
856            user_id: None,
857            transport: 1,
858            address: "127.0.0.1:8090".to_owned(),
859            consumer_groups_count: 0,
860            metadata: Some(b"card".to_vec()),
861        };
862        assert!(ok.validate().is_ok());
863    }
864}