Skip to main content

laser_wire/
agent.rs

1// The Agent Data Exchange Protocol (AGDX) wire surface: the versioned on-log envelope that
2// agent traffic rides, its id types, the task-state and error-code
3// dictionaries, the dead-letter capsule, and the per-kind validity matrix.
4// Specified and fixtured like every other surface. The server stays a thin
5// router (no managed commands, no fork delta), so AGDX works on raw Apache Iggy
6// too.
7//
8// Versioning is out of band. A log record is durable and read back for years,
9// so the `agdx.av` header (u32, `AGENT_OP_VERSION`) selects the decoder before
10// any byte of the body is read. The envelope carries no `v` field by design.
11//
12// Identity is a claim, by deliberate and permanent design. `source` is not
13// infrastructure-stamped identity. Per-record authorship comes from topology
14// (write-exclusive topics per principal) and, in a future envelope version,
15// from signatures. `usage` is advisory analytics input, never
16// enforcement-grade accounting.
17
18use crate::error::InvalidError;
19use crate::query::Value;
20use serde::de::{self, Visitor};
21use serde::{Deserialize, Deserializer, Serialize, Serializer};
22use std::collections::BTreeMap;
23use std::fmt;
24use std::str::FromStr;
25
26/// Why parsing a Crockford base32 id failed.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
28#[non_exhaustive]
29pub enum IdParseError {
30    #[error("id must be 26 characters, got {got}")]
31    Length { got: usize },
32    #[error("id contains invalid character `{0}`")]
33    Char(char),
34    #[error("id overflows 128 bits")]
35    Overflow,
36}
37
38// Every u128 id rides the CBOR payload as one atomic 16-byte byte string
39// (big-endian), a fixed-width form with no bignum tag. Duplicated routing
40// HEADERS use Iggy's typed Uint128 representation instead (little-endian on the
41// server wire). Fixtures pin both so the two encodings cannot drift silently.
42macro_rules! wire_id {
43    ($(#[$doc:meta])* $name:ident) => {
44        $(#[$doc])*
45        #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
46        pub struct $name(u128);
47
48        impl $name {
49            /// Wrap a raw 128-bit id.
50            pub const fn from_u128(value: u128) -> Self {
51                Self(value)
52            }
53
54            /// The raw 128-bit value.
55            pub const fn as_u128(self) -> u128 {
56                self.0
57            }
58
59            /// The big-endian 16 bytes (the payload wire form).
60            pub const fn to_bytes(self) -> [u8; 16] {
61                self.0.to_be_bytes()
62            }
63
64            /// An id from its big-endian 16 bytes.
65            pub const fn from_bytes(payload: [u8; 16]) -> Self {
66                Self(u128::from_be_bytes(payload))
67            }
68        }
69
70        impl std::fmt::Display for $name {
71            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72                let encoded = $crate::agent::crockford_encode(self.0);
73                // The alphabet is ASCII, so the buffer is always valid UTF-8.
74                f.write_str(std::str::from_utf8(&encoded).expect("crockford output is ASCII"))
75            }
76        }
77
78        impl std::fmt::Debug for $name {
79            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80                write!(f, "{}({})", stringify!($name), self)
81            }
82        }
83
84        impl std::str::FromStr for $name {
85            type Err = $crate::agent::IdParseError;
86
87            fn from_str(s: &str) -> Result<Self, Self::Err> {
88                $crate::agent::crockford_decode(s).map(Self)
89            }
90        }
91
92        impl From<u128> for $name {
93            fn from(value: u128) -> Self {
94                Self(value)
95            }
96        }
97
98        impl From<$name> for u128 {
99            fn from(value: $name) -> u128 {
100                value.0
101            }
102        }
103
104        impl serde::Serialize for $name {
105            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
106                if serializer.is_human_readable() {
107                    serializer.serialize_str(&self.to_string())
108                } else {
109                    serializer.serialize_bytes(&self.to_bytes())
110                }
111            }
112        }
113
114        impl<'de> serde::Deserialize<'de> for $name {
115            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
116                if deserializer.is_human_readable() {
117                    struct TextVisitor;
118
119                    impl<'de> serde::de::Visitor<'de> for TextVisitor {
120                        type Value = $name;
121
122                        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123                            f.write_str("a 26-character Crockford base32 id or 16 id bytes")
124                        }
125
126                        fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
127                            value.parse().map_err(E::custom)
128                        }
129
130                        fn visit_bytes<E: serde::de::Error>(self, value: &[u8]) -> Result<Self::Value, E> {
131                            let payload: [u8; 16] = value
132                                .try_into()
133                                .map_err(|_| E::invalid_length(value.len(), &self))?;
134                            Ok($name::from_bytes(payload))
135                        }
136
137                        fn visit_byte_buf<E: serde::de::Error>(self, value: Vec<u8>) -> Result<Self::Value, E> {
138                            self.visit_bytes(&value)
139                        }
140                    }
141
142                    // Derived enum decoders buffer newtype payloads before replaying
143                    // them. Serde's buffered deserializer reports human-readable mode,
144                    // but preserves the original CBOR byte-string value.
145                    return deserializer.deserialize_any(TextVisitor);
146                }
147
148                struct BytesVisitor;
149
150                impl<'de> serde::de::Visitor<'de> for BytesVisitor {
151                    type Value = $name;
152
153                    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154                        f.write_str("16 big-endian id bytes")
155                    }
156
157                    fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
158                        let payload: [u8; 16] = v
159                            .try_into()
160                            .map_err(|_| E::invalid_length(v.len(), &self))?;
161                        Ok($name::from_bytes(payload))
162                    }
163
164                    fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<Self::Value, E> {
165                        self.visit_bytes(&v)
166                    }
167
168                    fn visit_seq<A: serde::de::SeqAccess<'de>>(
169                        self,
170                        mut sequence: A,
171                    ) -> Result<Self::Value, A::Error> {
172                        let mut payload = [0_u8; 16];
173                        for (index, byte) in payload.iter_mut().enumerate() {
174                            *byte = sequence
175                                .next_element()?
176                                .ok_or_else(|| serde::de::Error::invalid_length(index, &self))?;
177                        }
178                        if sequence.next_element::<u8>()?.is_some() {
179                            return Err(serde::de::Error::invalid_length(17, &self));
180                        }
181                        Ok($name::from_bytes(payload))
182                    }
183                }
184
185                deserializer.deserialize_bytes(BytesVisitor)
186            }
187        }
188    };
189}
190
191// Reused by the memory and graph modules for their id newtypes (MemoryId,
192// NodeId, EdgeId), so every wire id shares one display, codec, and parse form.
193pub(crate) use wire_id;
194
195wire_id!(
196    /// A record's producer-assigned identity, a ULID minted before publish.
197    ///
198    /// Portable in a way a log position is not. An offset is meaningful only
199    /// within the partition that assigned it, so a copy elsewhere (a re-publish
200    /// into another stream, partition, or DR cluster) gets a fresh position.
201    /// This id rides in the payload and stays the same everywhere.
202    RecordId
203);
204wire_id!(
205    /// The conversation a message belongs to. The unit of ordering and the
206    /// partition key, and the trace id of the causal trace.
207    ConversationId
208);
209wire_id!(
210    /// Request/reply pairing id. A2A task identity and MCP tool-call ids map
211    /// onto it at the bridges.
212    CorrelationId
213);
214wire_id!(
215    /// A chunk stream's grouping id when several streams run under one
216    /// correlation. Named `channel` because `stream` is an iggy topology term.
217    ChannelId
218);
219
220/// The Iggy binding's packing of the causal locator (`cause_at`, and the
221/// dead-letter `source`).
222///
223/// The locator rides the wire as one **opaque byte string**, the substrate-
224/// neutral slot every binding packs its own form into, so the envelope itself
225/// names no server. A consumer that cannot interpret the bytes ignores them and
226/// falls back to the portable `cause` / `record` id. The Iggy packing is the
227/// four-level address as fixed-width big-endian payload: `stream_id`, `topic_id`,
228/// `partition_id` (each u32) then `offset` (u64), 20 bytes. Another binding
229/// packs its own locator (a topic name or id, a partition, an offset) into the
230/// same opaque slot.
231#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
232pub struct LogPosition {
233    pub stream_id: u32,
234    pub topic_id: u32,
235    pub partition_id: u32,
236    pub offset: u64,
237}
238
239const LOG_POSITION_BYTES: usize = 20;
240
241impl LogPosition {
242    /// A locator at `(stream, topic, partition, offset)`.
243    pub const fn new(stream_id: u32, topic_id: u32, partition_id: u32, offset: u64) -> Self {
244        Self {
245            stream_id,
246            topic_id,
247            partition_id,
248            offset,
249        }
250    }
251
252    /// The Iggy locator packed as 20 big-endian bytes (the opaque wire form).
253    pub fn to_bytes(self) -> [u8; LOG_POSITION_BYTES] {
254        let mut out = [0u8; LOG_POSITION_BYTES];
255        out[0..4].copy_from_slice(&self.stream_id.to_be_bytes());
256        out[4..8].copy_from_slice(&self.topic_id.to_be_bytes());
257        out[8..12].copy_from_slice(&self.partition_id.to_be_bytes());
258        out[12..20].copy_from_slice(&self.offset.to_be_bytes());
259        out
260    }
261
262    /// Unpack the Iggy locator from its 20 big-endian bytes.
263    pub fn from_bytes(payload: [u8; LOG_POSITION_BYTES]) -> Self {
264        let u32_at = |start: usize| {
265            u32::from_be_bytes(payload[start..start + 4].try_into().expect("4-byte slice"))
266        };
267        Self {
268            stream_id: u32_at(0),
269            topic_id: u32_at(4),
270            partition_id: u32_at(8),
271            offset: u64::from_be_bytes(payload[12..20].try_into().expect("8-byte slice")),
272        }
273    }
274}
275
276// The locator rides the payload as one opaque CBOR byte string, not a named-field
277// map, so the slot is binding-neutral (an Iggy packing here, another binding's
278// packing elsewhere) and pins smaller than the four labelled fields would.
279impl Serialize for LogPosition {
280    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
281        serializer.serialize_bytes(&self.to_bytes())
282    }
283}
284
285impl<'de> Deserialize<'de> for LogPosition {
286    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
287        struct LocatorVisitor;
288
289        impl<'de> Visitor<'de> for LocatorVisitor {
290            type Value = LogPosition;
291
292            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293                f.write_str("20 packed locator bytes")
294            }
295
296            fn visit_bytes<E: de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
297                let payload: [u8; LOG_POSITION_BYTES] = v
298                    .try_into()
299                    .map_err(|_| E::invalid_length(v.len(), &self))?;
300                Ok(LogPosition::from_bytes(payload))
301            }
302
303            fn visit_byte_buf<E: de::Error>(self, v: Vec<u8>) -> Result<Self::Value, E> {
304                self.visit_bytes(&v)
305            }
306
307            fn visit_seq<A: de::SeqAccess<'de>>(
308                self,
309                mut sequence: A,
310            ) -> Result<Self::Value, A::Error> {
311                let mut payload = [0_u8; LOG_POSITION_BYTES];
312                for (index, byte) in payload.iter_mut().enumerate() {
313                    *byte = sequence
314                        .next_element()?
315                        .ok_or_else(|| de::Error::invalid_length(index, &self))?;
316                }
317                if sequence.next_element::<u8>()?.is_some() {
318                    return Err(de::Error::invalid_length(LOG_POSITION_BYTES + 1, &self));
319                }
320                Ok(LogPosition::from_bytes(payload))
321            }
322        }
323
324        deserializer.deserialize_bytes(LocatorVisitor)
325    }
326}
327
328/// A producer-supplied business idempotency key: non-empty, at most 64 bytes.
329///
330/// A readable string by design, often a natural business key like
331/// `job-123-attempt-2`, so consoles and dead-letter capsules stay debuggable.
332/// The reliable consumer's dedup store hashes it internally.
333#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
334#[serde(try_from = "String", into = "String")]
335pub struct IdempotencyKey(String);
336
337impl IdempotencyKey {
338    /// The key as a string slice.
339    pub fn as_str(&self) -> &str {
340        &self.0
341    }
342}
343
344impl FromStr for IdempotencyKey {
345    type Err = InvalidError;
346
347    fn from_str(s: &str) -> Result<Self, Self::Err> {
348        s.to_owned().try_into()
349    }
350}
351
352impl TryFrom<String> for IdempotencyKey {
353    type Error = InvalidError;
354
355    fn try_from(value: String) -> Result<Self, Self::Error> {
356        if value.is_empty() {
357            return Err(InvalidError::new("idempotency key must not be empty"));
358        }
359        if value.len() > crate::limits::MAX_IDEMPOTENCY_KEY_BYTES {
360            return Err(InvalidError::new(format!(
361                "idempotency key is {}B, exceeds cap {}B",
362                value.len(),
363                crate::limits::MAX_IDEMPOTENCY_KEY_BYTES
364            )));
365        }
366        if value.chars().any(char::is_control) {
367            return Err(InvalidError::new(
368                "idempotency key contains a control character",
369            ));
370        }
371        Ok(Self(value))
372    }
373}
374
375impl From<IdempotencyKey> for String {
376    fn from(value: IdempotencyKey) -> Self {
377        value.0
378    }
379}
380
381impl fmt::Display for IdempotencyKey {
382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383        f.write_str(&self.0)
384    }
385}
386
387/// An agent's identity: a bounded, human-readable name string.
388///
389/// A string rather than an opaque numeric id, because an agent is a named
390/// principal every edge protocol spells out as text (an A2A agent name or URL,
391/// an MCP server name, OTel `gen_ai.agent.id`). The SDK's named agents map
392/// straight onto it with no lossy hash. It is an authorship claim on shared
393/// topics (see the module docs). Non-empty, at most
394/// [`MAX_AGENT_STRING_BYTES`](crate::limits::MAX_AGENT_STRING_BYTES), and free
395/// of ASCII control characters. Every other character is allowed.
396#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
397#[serde(try_from = "String", into = "String")]
398pub struct AgentId(String);
399
400impl AgentId {
401    /// The id as a string slice.
402    pub fn as_str(&self) -> &str {
403        &self.0
404    }
405}
406
407impl FromStr for AgentId {
408    type Err = InvalidError;
409
410    fn from_str(s: &str) -> Result<Self, Self::Err> {
411        s.to_owned().try_into()
412    }
413}
414
415impl TryFrom<String> for AgentId {
416    type Error = InvalidError;
417
418    fn try_from(value: String) -> Result<Self, Self::Error> {
419        if value.is_empty() {
420            return Err(InvalidError::new("agent id must not be empty"));
421        }
422        if value.len() > crate::limits::MAX_AGENT_STRING_BYTES {
423            return Err(InvalidError::new(format!(
424                "agent id is {}B, exceeds cap {}B",
425                value.len(),
426                crate::limits::MAX_AGENT_STRING_BYTES
427            )));
428        }
429        if let Some(c) = value.chars().find(|c| c.is_control()) {
430            return Err(InvalidError::new(format!(
431                "agent id must not contain control characters (found {c:?})"
432            )));
433        }
434        Ok(Self(value))
435    }
436}
437
438impl TryFrom<&str> for AgentId {
439    type Error = InvalidError;
440
441    fn try_from(value: &str) -> Result<Self, Self::Error> {
442        value.to_owned().try_into()
443    }
444}
445
446impl From<AgentId> for String {
447    fn from(value: AgentId) -> Self {
448        value.0
449    }
450}
451
452impl fmt::Display for AgentId {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        f.write_str(&self.0)
455    }
456}
457
458/// What a message is. A closed vocabulary by design: adding a kind requires an
459/// `AGENT_OP_VERSION` bump and a hello advertisement, because an unknown kind
460/// must fail decode rather than flow misinterpreted.
461#[derive(
462    Clone,
463    Copy,
464    Debug,
465    PartialEq,
466    Eq,
467    Hash,
468    Serialize,
469    Deserialize,
470    strum::Display,
471    strum::EnumString,
472)]
473#[serde(rename_all = "snake_case")]
474#[strum(serialize_all = "snake_case")]
475pub enum AgentKind {
476    /// Expects a reply or effect. Requires `correlation`. Fire-and-forget
477    /// commands do not exist by definition. Those are events.
478    Command,
479    /// The paired answer to a command.
480    Response,
481    /// Expects nothing.
482    Event,
483    /// One piece of a stream, ordered by `sequence` within a `channel`.
484    Chunk,
485    /// Lifecycle signal, discriminated by `operation`: task or session updates,
486    /// liveness cards, and progress ticks.
487    Status,
488    /// A terminal failure. The body is a structured [`AgentErrorBody`].
489    Error,
490}
491
492/// A2A's task lifecycle with LaserData's `Paused` extension, riding the wire as a u8 code (the
493/// `agdx.ct` dictionary pattern) so a future A2A state takes the next free code
494/// and flows through old consumers as an opaque non-terminal value instead of
495/// forcing a version bump on someone else's release schedule. Codes are
496/// permanent and never renumbered.
497// Display and FromStr are hand-written, not strum-derived, deliberately: the
498// `Unrecognized(u8)` passthrough variant must format its carried code
499// (`unrecognized-42`) and must never parse from text, a combination strum's
500// per-variant attributes cannot express (`disabled` removes the variant from
501// Display too and the derive panics on it). Every pure unit-word vocabulary
502// (`AgentRunState`, `ForkKind`, `ContentType`, ...) derives strum instead.
503#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
504#[serde(from = "u8", into = "u8")]
505pub enum TaskState {
506    Submitted,
507    Working,
508    InputRequired,
509    Completed,
510    Canceled,
511    Failed,
512    Rejected,
513    AuthRequired,
514    Unknown,
515    /// LaserData's paused session state.
516    Paused,
517    /// A code this build does not know: passed through, treated as non-terminal.
518    Unrecognized(u8),
519}
520
521impl TaskState {
522    /// The pinned wire code.
523    pub const fn code(self) -> u8 {
524        match self {
525            TaskState::Submitted => 1,
526            TaskState::Working => 2,
527            TaskState::InputRequired => 3,
528            TaskState::Completed => 4,
529            TaskState::Canceled => 5,
530            TaskState::Failed => 6,
531            TaskState::Rejected => 7,
532            TaskState::AuthRequired => 8,
533            TaskState::Unknown => 9,
534            TaskState::Paused => 10,
535            TaskState::Unrecognized(code) => code,
536        }
537    }
538
539    /// The state for a wire code (total: unknown codes become
540    /// [`Unrecognized`](Self::Unrecognized)).
541    pub const fn from_code(code: u8) -> Self {
542        match code {
543            1 => TaskState::Submitted,
544            2 => TaskState::Working,
545            3 => TaskState::InputRequired,
546            4 => TaskState::Completed,
547            5 => TaskState::Canceled,
548            6 => TaskState::Failed,
549            7 => TaskState::Rejected,
550            8 => TaskState::AuthRequired,
551            9 => TaskState::Unknown,
552            10 => TaskState::Paused,
553            other => TaskState::Unrecognized(other),
554        }
555    }
556
557    /// Whether this state ends the task (A2A's terminal set). Unrecognized
558    /// codes are non-terminal by rule.
559    pub const fn is_terminal(self) -> bool {
560        matches!(
561            self,
562            TaskState::Completed | TaskState::Canceled | TaskState::Failed | TaskState::Rejected
563        )
564    }
565}
566
567impl From<u8> for TaskState {
568    fn from(code: u8) -> Self {
569        Self::from_code(code)
570    }
571}
572
573impl From<TaskState> for u8 {
574    fn from(state: TaskState) -> u8 {
575        state.code()
576    }
577}
578
579impl fmt::Display for TaskState {
580    // The A2A kebab-case names, for bridges and consoles.
581    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
582        match self {
583            TaskState::Submitted => f.write_str("submitted"),
584            TaskState::Working => f.write_str("working"),
585            TaskState::InputRequired => f.write_str("input-required"),
586            TaskState::Completed => f.write_str("completed"),
587            TaskState::Canceled => f.write_str("canceled"),
588            TaskState::Failed => f.write_str("failed"),
589            TaskState::Rejected => f.write_str("rejected"),
590            TaskState::AuthRequired => f.write_str("auth-required"),
591            TaskState::Unknown => f.write_str("unknown"),
592            TaskState::Paused => f.write_str("paused"),
593            TaskState::Unrecognized(code) => write!(f, "unrecognized-{code}"),
594        }
595    }
596}
597
598impl FromStr for TaskState {
599    type Err = InvalidError;
600
601    fn from_str(s: &str) -> Result<Self, Self::Err> {
602        Ok(match s {
603            "submitted" => TaskState::Submitted,
604            "working" => TaskState::Working,
605            "input-required" => TaskState::InputRequired,
606            "completed" => TaskState::Completed,
607            "canceled" => TaskState::Canceled,
608            "failed" => TaskState::Failed,
609            "rejected" => TaskState::Rejected,
610            "auth-required" => TaskState::AuthRequired,
611            "unknown" => TaskState::Unknown,
612            "paused" => TaskState::Paused,
613            other => return Err(InvalidError::new(format!("unknown task state `{other}`"))),
614        })
615    }
616}
617
618/// The session lifecycle exposed by managed reads.
619#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(rename_all = "snake_case")]
621pub enum SessionStatus {
622    Submitted,
623    Active,
624    Paused,
625    Completed,
626    Failed,
627    Canceled,
628    #[serde(other)]
629    Unrecognized,
630}
631
632/// One session addressed within a stream by name.
633#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
634pub struct SessionRef {
635    pub stream: String,
636    pub session: ConversationId,
637}
638
639/// The SDK that created a session.
640#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
641pub struct SdkInfo {
642    pub language: String,
643    pub version: String,
644}
645
646/// The optional token and cost ceiling requested for a session.
647#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
648pub struct Budget {
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub tokens: Option<u64>,
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub cost_micros: Option<u64>,
653}
654
655/// The first status body of a session.
656#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
657pub struct SessionStart {
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub label: Option<String>,
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub namespace: Option<String>,
662    pub agent: AgentId,
663    pub sdk: SdkInfo,
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub parent: Option<ConversationId>,
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub root: Option<ConversationId>,
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub idle_timeout_micros: Option<u64>,
670    #[serde(default, skip_serializing_if = "Option::is_none")]
671    pub budget: Option<Budget>,
672    #[serde(default, skip_serializing_if = "Vec::is_empty")]
673    pub tags: Vec<String>,
674}
675
676/// A session status update that can acknowledge a control record.
677#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
678pub struct SessionTransition {
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub actor: Option<AgentId>,
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub acknowledges: Option<LogPosition>,
683}
684
685/// The JSON body of a `session_pause` request on `agent.control`: the agents
686/// whose acknowledgments complete the pause, frozen when the request is
687/// written. An empty set names no agent, and every agent that receives work
688/// for the session while it is paused acknowledges when it holds that work.
689#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
690pub struct SessionPauseRequest {
691    #[serde(default, skip_serializing_if = "Vec::is_empty")]
692    pub participants: Vec<AgentId>,
693}
694
695/// One work record an agent held while its session was paused. A
696/// `session_parked` event on the session lane carries it, and a
697/// `session_unparked` event with the same body records that the agent handled
698/// the record after the resume. The source address with its topic generation,
699/// the agent, and the pause request position together identify one parking.
700#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
701pub struct SessionParking {
702    pub source: crate::graph::SourceRef,
703    pub role: AgentId,
704    pub request: LogPosition,
705}
706
707impl crate::validate::Validate for SessionParking {
708    fn validate(&self) -> Result<(), InvalidError> {
709        match &self.source {
710            crate::graph::SourceRef::Message {
711                generation: Some(_),
712                ..
713            } => Ok(()),
714            crate::graph::SourceRef::Message { .. } => Err(InvalidError::new(
715                "a parked record must name its source topic generation",
716            )),
717            _ => Err(InvalidError::new(
718                "a parked record must point at a log record",
719            )),
720        }
721    }
722}
723
724/// The body of a terminal session status.
725#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
726pub struct SessionEnd {
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub reason: Option<String>,
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub error: Option<AgentErrorBody>,
731}
732
733/// The estimated token count of `bytes` bytes of context: one token per four
734/// bytes, rounded up. Every SDK and the session fold use this one estimate.
735pub const fn estimate_tokens(bytes: usize) -> u64 {
736    bytes.div_ceil(4) as u64
737}
738
739/// The metadata key for a model call's requested model.
740pub const METADATA_REQUEST_MODEL: &str = "gen_ai.request.model";
741/// The metadata key for the model that answered a call.
742pub const METADATA_RESPONSE_MODEL: &str = "gen_ai.response.model";
743/// The metadata key for the provider that served a model call.
744pub const METADATA_PROVIDER_NAME: &str = "gen_ai.provider.name";
745/// The metadata key that marks a command as the first command of a
746/// submitted session. The receiving agent marks the session working when it
747/// picks the command up.
748pub const METADATA_SUBMITTED: &str = "submitted";
749/// The metadata key that marks a command a session recorded for its own model
750/// or tool call. `classify` keeps such a record observational, so it never
751/// reaches a handler as new work.
752pub const METADATA_RECORDED: &str = "recorded";
753/// The metadata key for a call's duration in microseconds, measured by the
754/// application around the provider or tool call.
755pub const METADATA_DURATION_MICROS: &str = "duration_micros";
756
757/// The inputs and source positions used to assemble one model context.
758#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
759pub struct ContextManifest {
760    pub policy: String,
761    pub policy_version: String,
762    pub fragments: Vec<Fragment>,
763    pub tokens: u64,
764    pub bytes: u64,
765    pub frontier: Vec<(u32, u32, u64)>,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub correlation: Option<CorrelationId>,
768}
769
770/// One input in the context that a model received.
771#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
772pub enum Fragment {
773    Message {
774        at: crate::graph::SourceRef,
775        tokens: u32,
776        bytes: u32,
777    },
778    Memory {
779        id: String,
780        #[serde(default, skip_serializing_if = "Option::is_none")]
781        version: Option<u64>,
782        #[serde(default, skip_serializing_if = "Option::is_none")]
783        digest: Option<crate::schema::Digest32>,
784        tokens: u32,
785        bytes: u32,
786    },
787    Kv {
788        namespace: String,
789        key: String,
790        #[serde(default, skip_serializing_if = "Option::is_none")]
791        version: Option<u64>,
792        #[serde(default, skip_serializing_if = "Option::is_none")]
793        digest: Option<crate::schema::Digest32>,
794        tokens: u32,
795        bytes: u32,
796    },
797    State {
798        key: String,
799        revision: u64,
800        tokens: u32,
801        bytes: u32,
802    },
803    Summary {
804        at: crate::graph::SourceRef,
805        tokens: u32,
806        bytes: u32,
807    },
808}
809
810/// A summary that replaces a range of context records.
811#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
812pub struct ContextCompaction {
813    pub summary_at: crate::graph::SourceRef,
814    pub covered: Vec<(u32, u32, u64, u64)>,
815    pub summarizer: crate::graph::ProducerInfo,
816}
817
818/// The items selected by one context retrieval.
819#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
820pub struct ContextRetrieval {
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub query: Option<String>,
823    pub items: Vec<(String, f32)>,
824}
825
826/// One RFC 6902 operation of a state patch.
827pub use json_patch::PatchOperation;
828
829/// One revision-guarded RFC 6902 state patch.
830#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
831pub struct StateDelta {
832    pub base_revision: u64,
833    pub patch: Vec<json_patch::PatchOperation>,
834    pub op_id: String,
835}
836
837/// A complete state document at the applied revision it replaces.
838#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
839pub struct StateSnapshot {
840    pub base_revision: u64,
841    pub document: serde_json::Value,
842}
843
844impl crate::validate::Validate for ContextManifest {
845    fn validate(&self) -> Result<(), InvalidError> {
846        if self.fragments.len() > crate::limits::MAX_MANIFEST_FRAGMENTS {
847            return Err(InvalidError::new("context manifest has too many fragments"));
848        }
849        for fragment in &self.fragments {
850            let digest = match fragment {
851                Fragment::Memory { digest, .. } | Fragment::Kv { digest, .. } => digest.as_ref(),
852                _ => None,
853            };
854            if let Some(digest) = digest {
855                crate::validate::Validate::validate(digest)?;
856            }
857        }
858        Ok(())
859    }
860}
861
862impl crate::validate::Validate for StateDelta {
863    fn validate(&self) -> Result<(), InvalidError> {
864        if self.patch.len() > crate::limits::MAX_STATE_PATCH_OPS {
865            return Err(InvalidError::new("state patch has too many operations"));
866        }
867        if self.op_id.is_empty() {
868            return Err(InvalidError::new(
869                "state patch operation id must not be empty",
870            ));
871        }
872        let bytes = serde_json::to_vec(&self.patch)
873            .map_err(|error| InvalidError::new(error.to_string()))?;
874        if bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
875            return Err(InvalidError::new(
876                "state patch exceeds the document byte cap",
877            ));
878        }
879        let patch = serde_json::to_value(&self.patch)
880            .map_err(|error| InvalidError::new(error.to_string()))?;
881        validate_portable_json(&patch)?;
882        Ok(())
883    }
884}
885
886impl crate::validate::Validate for StateSnapshot {
887    fn validate(&self) -> Result<(), InvalidError> {
888        let bytes = serde_json::to_vec(&self.document)
889            .map_err(|error| InvalidError::new(error.to_string()))?;
890        if bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
891            return Err(InvalidError::new("state document exceeds its byte cap"));
892        }
893        validate_portable_json(&self.document)?;
894        Ok(())
895    }
896}
897
898const MAX_EXACT_JSON_INTEGER: i128 = 9_007_199_254_740_991;
899
900fn validate_portable_json(value: &serde_json::Value) -> Result<(), InvalidError> {
901    match value {
902        serde_json::Value::Number(number) => {
903            let exact = if let Some(integer) = number.as_i64() {
904                i128::from(integer).abs() <= MAX_EXACT_JSON_INTEGER
905            } else if let Some(integer) = number.as_u64() {
906                i128::from(integer) <= MAX_EXACT_JSON_INTEGER
907            } else {
908                number.as_f64().is_some_and(|float| {
909                    float.is_finite()
910                        && (float.fract() != 0.0 || float.abs() <= MAX_EXACT_JSON_INTEGER as f64)
911                })
912            };
913            if !exact {
914                return Err(InvalidError::new(
915                    "state JSON integer exceeds the exact number range",
916                ));
917            }
918        }
919        serde_json::Value::Array(items) => {
920            for item in items {
921                validate_portable_json(item)?;
922            }
923        }
924        serde_json::Value::Object(fields) => {
925            for item in fields.values() {
926                validate_portable_json(item)?;
927            }
928        }
929        _ => {}
930    }
931    Ok(())
932}
933
934/// Apply a JSON Patch to a copy of a state document and check the shared limits.
935pub fn apply_json_patch(
936    document: &serde_json::Value,
937    patch: &[json_patch::PatchOperation],
938) -> Result<serde_json::Value, InvalidError> {
939    if patch.len() > crate::limits::MAX_STATE_PATCH_OPS {
940        return Err(InvalidError::new("state patch has too many operations"));
941    }
942    let patch_bytes =
943        serde_json::to_vec(patch).map_err(|error| InvalidError::new(error.to_string()))?;
944    if patch_bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
945        return Err(InvalidError::new(
946            "state patch exceeds the document byte cap",
947        ));
948    }
949    validate_portable_json(document)?;
950    let patch_value =
951        serde_json::to_value(patch).map_err(|error| InvalidError::new(error.to_string()))?;
952    validate_portable_json(&patch_value)?;
953    let mut result = document.clone();
954    json_patch::patch(&mut result, patch).map_err(|error| InvalidError::new(error.to_string()))?;
955    crate::validate::Validate::validate(&StateSnapshot {
956        base_revision: 0,
957        document: result.clone(),
958    })?;
959    Ok(result)
960}
961
962impl crate::validate::Validate for SessionStart {
963    fn validate(&self) -> Result<(), InvalidError> {
964        if let Some(label) = &self.label
965            && (label.len() > crate::limits::MAX_SESSION_LABEL_BYTES
966                || label.chars().any(char::is_control))
967        {
968            return Err(InvalidError::new(
969                "session label exceeds its cap or contains a control character",
970            ));
971        }
972        if let Some(namespace) = &self.namespace {
973            crate::kv::validate_namespace(namespace)?;
974        }
975        if self.root.is_some() && self.parent.is_none() {
976            return Err(InvalidError::new("session root requires a parent"));
977        }
978        if self.tags.len() > crate::limits::MAX_MEMORY_TAGS {
979            return Err(InvalidError::new("session has too many tags"));
980        }
981        if self
982            .tags
983            .iter()
984            .any(|tag| tag.len() > crate::limits::MAX_MEMORY_TAG_BYTES)
985        {
986            return Err(InvalidError::new("session tag exceeds its byte cap"));
987        }
988        Ok(())
989    }
990}
991
992/// Typed token accounting, OTel-aligned (`gen_ai.usage.*`, the current
993/// `input_tokens` and `output_tokens` names, never the deprecated
994/// prompt/completion pair).
995///
996/// Advisory analytics input. It is agent-written, so budgets enforce where the
997/// LLM call executes, never on this field.
998#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
999pub struct TokenUsage {
1000    pub input_tokens: u64,
1001    pub output_tokens: u64,
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub reasoning_output_tokens: Option<u64>,
1004    #[serde(default, skip_serializing_if = "Option::is_none")]
1005    pub cache_read_input_tokens: Option<u64>,
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub cache_creation_input_tokens: Option<u64>,
1008    /// Cost in integer micro-units of the deployment's configured currency.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub cost_micros: Option<u64>,
1011}
1012
1013/// Why an agent operation failed, as a pinned u8 dictionary (the
1014/// [`TaskState`] pattern: unknown codes decode and pass through).
1015#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1016#[serde(from = "u8", into = "u8")]
1017pub enum AgentErrorCode {
1018    InvalidRequest,
1019    Unauthorized,
1020    Unsupported,
1021    DeadlineExceeded,
1022    Cancelled,
1023    ToolFailure,
1024    Internal,
1025    /// A code this build does not know: pass it through.
1026    Unrecognized(u8),
1027}
1028
1029impl AgentErrorCode {
1030    /// The pinned wire code.
1031    pub const fn code(self) -> u8 {
1032        match self {
1033            AgentErrorCode::InvalidRequest => 1,
1034            AgentErrorCode::Unauthorized => 2,
1035            AgentErrorCode::Unsupported => 3,
1036            AgentErrorCode::DeadlineExceeded => 4,
1037            AgentErrorCode::Cancelled => 5,
1038            AgentErrorCode::ToolFailure => 6,
1039            AgentErrorCode::Internal => 7,
1040            AgentErrorCode::Unrecognized(code) => code,
1041        }
1042    }
1043
1044    /// The error code for a wire code (total).
1045    pub const fn from_code(code: u8) -> Self {
1046        match code {
1047            1 => AgentErrorCode::InvalidRequest,
1048            2 => AgentErrorCode::Unauthorized,
1049            3 => AgentErrorCode::Unsupported,
1050            4 => AgentErrorCode::DeadlineExceeded,
1051            5 => AgentErrorCode::Cancelled,
1052            6 => AgentErrorCode::ToolFailure,
1053            7 => AgentErrorCode::Internal,
1054            other => AgentErrorCode::Unrecognized(other),
1055        }
1056    }
1057}
1058
1059impl From<u8> for AgentErrorCode {
1060    fn from(code: u8) -> Self {
1061        Self::from_code(code)
1062    }
1063}
1064
1065impl From<AgentErrorCode> for u8 {
1066    fn from(code: AgentErrorCode) -> u8 {
1067        code.code()
1068    }
1069}
1070
1071/// The structured body of a `kind = error` envelope, mirroring the wire error
1072/// enums of the other surfaces. The `code` is the machine discriminator, the
1073/// optional `message` is human detail.
1074#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1075pub struct AgentErrorBody {
1076    pub code: AgentErrorCode,
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub message: Option<String>,
1079    #[serde(default)]
1080    pub retryable: bool,
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub detail: Option<BTreeMap<String, Value>>,
1083}
1084
1085/// Why a message was dead-lettered, as a pinned u8 dictionary.
1086#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1087#[serde(from = "u8", into = "u8")]
1088pub enum DeadLetterReason {
1089    RetryExhausted,
1090    Rejected,
1091    DecodeFailed,
1092    DeadlineExceeded,
1093    /// A code this build does not know: pass it through.
1094    Unrecognized(u8),
1095}
1096
1097impl DeadLetterReason {
1098    /// The pinned wire code.
1099    pub const fn code(self) -> u8 {
1100        match self {
1101            DeadLetterReason::RetryExhausted => 1,
1102            DeadLetterReason::Rejected => 2,
1103            DeadLetterReason::DecodeFailed => 3,
1104            DeadLetterReason::DeadlineExceeded => 4,
1105            DeadLetterReason::Unrecognized(code) => code,
1106        }
1107    }
1108
1109    /// The reason for a wire code (total).
1110    pub const fn from_code(code: u8) -> Self {
1111        match code {
1112            1 => DeadLetterReason::RetryExhausted,
1113            2 => DeadLetterReason::Rejected,
1114            3 => DeadLetterReason::DecodeFailed,
1115            4 => DeadLetterReason::DeadlineExceeded,
1116            other => DeadLetterReason::Unrecognized(other),
1117        }
1118    }
1119}
1120
1121impl From<u8> for DeadLetterReason {
1122    fn from(code: u8) -> Self {
1123        Self::from_code(code)
1124    }
1125}
1126
1127impl From<DeadLetterReason> for u8 {
1128    fn from(reason: DeadLetterReason) -> u8 {
1129        reason.code()
1130    }
1131}
1132
1133/// The agent-level dead-letter capsule: the poison message's log position, the
1134/// reason, and the original payload verbatim.
1135///
1136/// The payload is the encoded [`AgentEnvelope`], byte-identical, so redrive is
1137/// trivially correct. Republish it to the source topic, or inspect it by
1138/// decoding the inner envelope. The capsule rides a dedicated dead-letter topic
1139/// in the agent stream, CBOR like everything else in AGDX. LaserData Cloud's own
1140/// dead-letter capsules stay operator-facing JSON on a different topic, for a
1141/// different audience.
1142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1143pub struct AgentDeadLetter {
1144    pub source: LogPosition,
1145    pub reason: DeadLetterReason,
1146    pub attempts: u32,
1147    #[serde(default, skip_serializing_if = "Option::is_none")]
1148    pub detail: Option<String>,
1149    #[serde(with = "crate::encoding::bin_bytes")]
1150    pub payload: Vec<u8>,
1151}
1152
1153/// An advertised skill's health, a pinned u8 dictionary (the [`TaskState`]
1154/// pattern: unknown codes pass through as [`Unrecognized`](Self::Unrecognized)).
1155#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1156#[serde(from = "u8", into = "u8")]
1157pub enum Health {
1158    Healthy,
1159    Degraded,
1160    Unavailable,
1161    /// A code this build does not know.
1162    Unrecognized(u8),
1163}
1164
1165impl Health {
1166    /// The pinned wire code.
1167    pub const fn code(self) -> u8 {
1168        match self {
1169            Health::Healthy => 1,
1170            Health::Degraded => 2,
1171            Health::Unavailable => 3,
1172            Health::Unrecognized(code) => code,
1173        }
1174    }
1175
1176    /// The health for a wire code (unknown codes become
1177    /// [`Unrecognized`](Self::Unrecognized)).
1178    pub const fn from_code(code: u8) -> Self {
1179        match code {
1180            1 => Health::Healthy,
1181            2 => Health::Degraded,
1182            3 => Health::Unavailable,
1183            other => Health::Unrecognized(other),
1184        }
1185    }
1186}
1187
1188impl From<u8> for Health {
1189    fn from(code: u8) -> Self {
1190        Self::from_code(code)
1191    }
1192}
1193
1194impl From<Health> for u8 {
1195    fn from(health: Health) -> u8 {
1196        health.code()
1197    }
1198}
1199
1200/// The content shape on a capability's input or output: either a content-type
1201/// or a registered writer-schema id. Externally tagged (`content_type` /
1202/// `schema_id`) rather than untagged: a content-type name and a schema id are
1203/// both strings, so an untagged encoding would decode a schema id literally named
1204/// `json` as [`ContentType::Json`](crate::content::ContentType::Json), and a
1205/// content-type name from a newer peer as a schema id. The tag makes the two
1206/// unambiguous and forward-safe.
1207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1208#[serde(rename_all = "snake_case")]
1209pub enum ContentRef {
1210    ContentType(crate::content::ContentType),
1211    SchemaId(String),
1212}
1213
1214/// A structured capability on an [`AgentCard`]: which skill, its I/O content
1215/// shape, advisory cost and latency classes, concurrency, health, and load.
1216/// Mirrors the SDK A2A `AgentSkill` so the bridge maps one to the other.
1217#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1218pub struct CapabilityDescriptor {
1219    /// The skill identifier, capped like every vocabulary string.
1220    pub skill_id: String,
1221    #[serde(default, skip_serializing_if = "Option::is_none")]
1222    pub input: Option<ContentRef>,
1223    #[serde(default, skip_serializing_if = "Option::is_none")]
1224    pub output: Option<ContentRef>,
1225    /// Advisory cost class (lower is cheaper), opaque to the protocol.
1226    #[serde(default, skip_serializing_if = "Option::is_none")]
1227    pub cost_class: Option<u8>,
1228    /// Advisory latency class (lower is faster), opaque to the protocol.
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub latency_class: Option<u8>,
1231    #[serde(default, skip_serializing_if = "Option::is_none")]
1232    pub max_concurrency: Option<u32>,
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub health: Option<Health>,
1235    /// Current load, per-mille of advertised capacity.
1236    #[serde(default, skip_serializing_if = "Option::is_none")]
1237    pub load: Option<u16>,
1238}
1239
1240/// The pinned minimal body of a liveness or capability card (`status` with
1241/// `operation = card`).
1242///
1243/// Without a pinned shape every bridge and viewer invents its own card variant.
1244/// The publishing agent rides the envelope's `source`, so the card carries only
1245/// what discovery needs. Anything richer is application data in `metadata` or a
1246/// follow-up body, never new card fields by convention.
1247#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1248pub struct AgentCard {
1249    /// Human-readable display label (a viewer shows the base32 id without
1250    /// it). Capped like every vocabulary string.
1251    #[serde(default, skip_serializing_if = "Option::is_none")]
1252    pub name: Option<String>,
1253    /// The agent's own version label, opaque to the protocol.
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub version: Option<String>,
1256    /// What the agent serves: structured capability descriptors, capped in both
1257    /// count and per-entry skill-id size.
1258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1259    pub capabilities: Vec<CapabilityDescriptor>,
1260    /// How long this card stays fresh. A card older than its ttl means a dead
1261    /// agent, the convention that makes cards liveness and not just discovery.
1262    #[serde(default, skip_serializing_if = "Option::is_none")]
1263    pub ttl_micros: Option<u64>,
1264    /// The topic the agent consumes its work on, the durable inbox a route
1265    /// resolves to when no live presence advertises one.
1266    #[serde(default, skip_serializing_if = "Option::is_none")]
1267    pub inbox: Option<String>,
1268}
1269
1270impl AgentCard {
1271    /// Check a decoded card against the caps.
1272    pub fn validate(&self) -> Result<(), ValidateError> {
1273        cap_str(self.name.as_deref(), "name")?;
1274        cap_str(self.version.as_deref(), "version")?;
1275        cap_str(self.inbox.as_deref(), "inbox")?;
1276        if self.capabilities.len() > crate::limits::MAX_CARD_CAPABILITIES {
1277            return Err(ValidateError::TooLarge {
1278                field: "capabilities",
1279                size: self.capabilities.len(),
1280                cap: crate::limits::MAX_CARD_CAPABILITIES,
1281            });
1282        }
1283        for capability in &self.capabilities {
1284            cap_str(Some(&capability.skill_id), "capability skill_id")?;
1285        }
1286        Ok(())
1287    }
1288}
1289
1290/// The live presence an agent advertises in its connection metadata
1291/// (`AGDX_SET_CLIENT_METADATA`), the body the discovery read surfaces per
1292/// connection.
1293///
1294/// Distinct from [`AgentCard`] on purpose. The card is durable, replayable
1295/// capability that outlives a brief disconnect (folded from the registry topic).
1296/// Presence is live transport truth: it vanishes on disconnect and
1297/// answers "where do I send this agent work right now," carrying the `inbox`
1298/// topic the agent is currently consuming, which may be a topic it created for
1299/// one workflow and drops when done. Routing resolves a capable agent to its
1300/// inbox through this, never through a hard-coded shared topic name, which does
1301/// not scale across users that each own their own streams and topics.
1302///
1303/// The body rides the opaque connection-metadata bytes with no envelope header,
1304/// so it carries its own version `v` ([`PRESENCE_OP_VERSION`](crate::codes::PRESENCE_OP_VERSION)).
1305/// The metadata channel stays opaque: a non-agent client advertises whatever blob
1306/// its own consumers interpret, and only an AGDX agent advertises this shape.
1307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1308pub struct AgentPresence {
1309    /// The presence body version, carried in-band (the metadata bytes have no
1310    /// out-of-band version header).
1311    pub v: u32,
1312    /// Which agent this connection is, the link from a connection (keyed by
1313    /// `client_id`/`user_id`) to its [`AgentCard`] (keyed by agent id). A claim
1314    /// like every agent-written field, cross-checked against the verified
1315    /// `user_id` the connection authenticated as.
1316    pub agent: AgentId,
1317    /// The topic this agent currently consumes its work on, within the stream its
1318    /// connection is scoped to. Absent means liveness-only presence with no
1319    /// declared inbox, so routing falls through to another signal.
1320    #[serde(default, skip_serializing_if = "Option::is_none")]
1321    pub inbox: Option<String>,
1322}
1323
1324impl AgentPresence {
1325    /// Presence for `agent` at the current [`PRESENCE_OP_VERSION`](crate::codes::PRESENCE_OP_VERSION),
1326    /// with no declared inbox yet.
1327    pub fn new(agent: AgentId) -> Self {
1328        Self {
1329            v: crate::codes::PRESENCE_OP_VERSION,
1330            agent,
1331            inbox: None,
1332        }
1333    }
1334
1335    /// Declare the `inbox` topic this agent consumes its work on.
1336    pub fn with_inbox(mut self, inbox: impl Into<String>) -> Self {
1337        self.inbox = Some(inbox.into());
1338        self
1339    }
1340
1341    /// Check a decoded presence body against the caps: the inbox topic is bounded
1342    /// like every vocabulary string.
1343    pub fn validate(&self) -> Result<(), ValidateError> {
1344        cap_str(self.inbox.as_deref(), "inbox")?;
1345        Ok(())
1346    }
1347}
1348
1349/// The claim-check capsule a `agdx.ct = ref` body carries.
1350///
1351/// The content lives elsewhere (object storage, the KV store, another topic).
1352/// The record carries where it lives, how big it is, and a digest, so any
1353/// consumer verifies the fetched bytes against the log without trusting the
1354/// store. The envelope and the validity matrix are untouched: a referenced
1355/// body is still a `body`, just one whose bytes are a `BodyRef` instead of the
1356/// content itself.
1357#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1358pub struct BodyRef {
1359    /// Where the bytes live: a URI, object key, or KV key. Bounded by
1360    /// [`MAX_BODY_REFERENCE_BYTES`](crate::limits::MAX_BODY_REFERENCE_BYTES).
1361    pub reference: String,
1362    /// The externalized content's size in bytes.
1363    pub size_bytes: u64,
1364    /// SHA-256 of the externalized content, exactly 32 bytes as a CBOR byte string.
1365    #[serde(with = "crate::encoding::bin_bytes")]
1366    pub sha256: Vec<u8>,
1367    /// Encryption scheme code, dormant like [`Signature::scheme`]. Absent means
1368    /// plaintext. Codes are assigned when the key registry (the same registry
1369    /// signatures verify against) lands.
1370    #[serde(default, skip_serializing_if = "Option::is_none")]
1371    pub encryption: Option<u8>,
1372}
1373
1374const SHA256_BYTES: usize = 32;
1375
1376impl BodyRef {
1377    /// A plaintext reference to externalized content.
1378    pub fn new(reference: impl Into<String>, size_bytes: u64, sha256: [u8; 32]) -> Self {
1379        Self {
1380            reference: reference.into(),
1381            size_bytes,
1382            sha256: sha256.to_vec(),
1383            encryption: None,
1384        }
1385    }
1386
1387    /// Check a decoded capsule: non-empty bounded `reference`, 32-byte digest.
1388    ///
1389    /// Receivers run it after decode. [`new`](Self::new) is valid by construction.
1390    pub fn validate(&self) -> Result<(), ValidateError> {
1391        if self.reference.is_empty() {
1392            return Err(ValidateError::Invalid {
1393                field: "reference",
1394                reason: "reference must not be empty".to_owned(),
1395            });
1396        }
1397        if self.reference.len() > crate::limits::MAX_BODY_REFERENCE_BYTES {
1398            return Err(ValidateError::TooLarge {
1399                field: "reference",
1400                size: self.reference.len(),
1401                cap: crate::limits::MAX_BODY_REFERENCE_BYTES,
1402            });
1403        }
1404        if self.sha256.len() != SHA256_BYTES {
1405            return Err(ValidateError::Invalid {
1406                field: "sha256",
1407                reason: format!(
1408                    "digest must be {SHA256_BYTES} bytes, got {}",
1409                    self.sha256.len()
1410                ),
1411            });
1412        }
1413        Ok(())
1414    }
1415}
1416
1417/// A detached envelope signature: designed but dormant.
1418///
1419/// The type exists so the future opt-in (per-agent keys, consumer-side
1420/// verification against a key registry) is an additive
1421/// `signature: Option<Signature>` envelope field, not a redesign. No crypto
1422/// dependency enters this crate. Signing and verification live SDK-side.
1423///
1424/// Field sizes are scheme-discriminated, so the `scheme` byte exists precisely
1425/// to keep the type from welding to one algorithm. The wire fields are bounded
1426/// bytes, [`validate`](Self::validate) enforces the registered per-scheme
1427/// lengths, and unknown schemes pass through.
1428#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1429pub struct Signature {
1430    /// The signing scheme code ([`SIGNATURE_SCHEME_ED25519`] = 1). Codes are
1431    /// permanent, and unknown codes pass through like every open dictionary.
1432    pub scheme: u8,
1433    /// Which of the agent's registered keys signed (8 bytes for Ed25519).
1434    #[serde(with = "crate::encoding::bin_bytes")]
1435    pub key_id: Vec<u8>,
1436    /// The signature over the canonical envelope encoding (signature field
1437    /// absent), domain-separated (64 bytes for Ed25519).
1438    #[serde(with = "crate::encoding::bin_bytes")]
1439    pub bytes: Vec<u8>,
1440    /// Out-of-band interpretation attributes the signature also covers, so an
1441    /// intermediary cannot flip the codec (`agdx.ct`) or wire version (`agdx.av`)
1442    /// on a signed record without invalidating it. Folded into the signed preimage
1443    /// when present. Skip-none, so a context-less signature stays byte-identical to
1444    /// a pre-context one (an older signer, or a body whose interpretation is fixed).
1445    #[serde(default, skip_serializing_if = "Option::is_none")]
1446    pub context: Option<SignatureContext>,
1447}
1448
1449/// The interpretation-bearing attributes a [`Signature`] binds beyond the
1450/// envelope body: the content-type code (`agdx.ct`) and wire version (`agdx.av`)
1451/// a consumer reads to select its codec and decoder. Riding unsigned headers,
1452/// these would let an intermediary reinterpret a signed record. Folded into the
1453/// preimage here, a change invalidates the signature.
1454#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1455pub struct SignatureContext {
1456    /// The `agdx.ct` content-type code the body is encoded with.
1457    #[serde(default, skip_serializing_if = "Option::is_none")]
1458    pub content_type: Option<u8>,
1459    /// The `agdx.av` wire version the envelope is stamped with.
1460    #[serde(default, skip_serializing_if = "Option::is_none")]
1461    pub agent_version: Option<u32>,
1462}
1463
1464/// The Ed25519 signing scheme code.
1465pub const SIGNATURE_SCHEME_ED25519: u8 = 1;
1466
1467/// The domain separator prefixed to the canonical envelope encoding before
1468/// signing, so an AGDX signature can never be replayed into another protocol.
1469/// The canonical encoding is this crate's own: named-field CBOR, fields in
1470/// declaration order, absent optionals skipped, the signature field absent.
1471pub const SIGNATURE_DOMAIN: &[u8] = b"agdx.signature.v1";
1472
1473const ED25519_KEY_ID_BYTES: usize = 8;
1474const ED25519_SIGNATURE_BYTES: usize = 64;
1475
1476impl Signature {
1477    /// Check a signature capsule against its scheme's registered lengths.
1478    ///
1479    /// Unknown scheme codes validate, so future schemes flow through old
1480    /// consumers. Verification itself is SDK-side and scheme-aware.
1481    pub fn validate(&self) -> Result<(), ValidateError> {
1482        if self.scheme != SIGNATURE_SCHEME_ED25519 {
1483            return Ok(());
1484        }
1485        if self.key_id.len() != ED25519_KEY_ID_BYTES {
1486            return Err(ValidateError::Invalid {
1487                field: "key_id",
1488                reason: format!(
1489                    "Ed25519 key id must be {ED25519_KEY_ID_BYTES} bytes, got {}",
1490                    self.key_id.len()
1491                ),
1492            });
1493        }
1494        if self.bytes.len() != ED25519_SIGNATURE_BYTES {
1495            return Err(ValidateError::Invalid {
1496                field: "bytes",
1497                reason: format!(
1498                    "Ed25519 signature must be {ED25519_SIGNATURE_BYTES} bytes, got {}",
1499                    self.bytes.len()
1500                ),
1501            });
1502        }
1503        Ok(())
1504    }
1505}
1506
1507/// The AGDX envelope: one CBOR named-field decode unit per agent message.
1508///
1509/// Field semantics, the per-kind validity matrix, and the caps are enforced by
1510/// [`validate`], and the per-kind constructors stamp the required shape. Routing
1511/// fields (`conversation`, `target`, content type) are also stamped as typed
1512/// headers so projections and plain Iggy consumers work without decoding
1513/// bodies. The envelope is the typed, versioned form of what the headers say.
1514///
1515/// `metadata` is the AGDX-native extension slot, distinct from headers (the
1516/// substrate and observability dictionary) and `body` (the content). Foreign
1517/// metadata (A2A `metadata`, MCP `_meta`) never maps into it. It tunnels whole
1518/// inside `body`, keeping bridge round trips byte-identical.
1519#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1520pub struct AgentEnvelope {
1521    pub kind: AgentKind,
1522    /// Producer-assigned record identity, required on every kind except
1523    /// `chunk` (chunks are identified by `channel` + `sequence`, saving the
1524    /// id bytes and a clock-and-entropy call per token).
1525    #[serde(default, skip_serializing_if = "Option::is_none")]
1526    pub record: Option<RecordId>,
1527    /// Also the partition key.
1528    pub conversation: ConversationId,
1529    /// The session this conversation was spawned from.
1530    #[serde(default, skip_serializing_if = "Option::is_none")]
1531    pub parent: Option<ConversationId>,
1532    /// The first conversation in this session tree.
1533    #[serde(default, skip_serializing_if = "Option::is_none")]
1534    pub root: Option<ConversationId>,
1535    /// Agent-authorship claim (see the module docs).
1536    pub source: AgentId,
1537    /// Routing refinement within a shared topic. The topic itself is the
1538    /// primary address. Consumer-side filtering by `target` is a convenience,
1539    /// not a confidentiality control. The topic is the boundary.
1540    #[serde(default, skip_serializing_if = "Option::is_none")]
1541    pub target: Option<AgentId>,
1542    /// The causal parent's record id: the identity half of the causal pointer,
1543    /// stable across replication.
1544    #[serde(default, skip_serializing_if = "Option::is_none")]
1545    pub cause: Option<RecordId>,
1546    /// The causal parent's log position: the locator half, an O(1) dereference
1547    /// for raw log walkers, deployment-local.
1548    #[serde(default, skip_serializing_if = "Option::is_none")]
1549    pub cause_at: Option<LogPosition>,
1550    /// Request/reply pairing. A2A task identity maps onto it.
1551    #[serde(default, skip_serializing_if = "Option::is_none")]
1552    pub correlation: Option<CorrelationId>,
1553    /// Chunk grouping when many streams run under one correlation.
1554    #[serde(default, skip_serializing_if = "Option::is_none")]
1555    pub channel: Option<ChannelId>,
1556    #[serde(default, skip_serializing_if = "Option::is_none")]
1557    pub idempotency_key: Option<IdempotencyKey>,
1558    /// Drop-dead time, epoch micros. On a stream-opening message it is also
1559    /// the reader-local abandonment bound: the producer knows its own model
1560    /// timeout, the consumer would be guessing.
1561    #[serde(default, skip_serializing_if = "Option::is_none")]
1562    pub deadline_micros: Option<u64>,
1563    /// Chunk ordering within a channel.
1564    #[serde(default, skip_serializing_if = "Option::is_none")]
1565    pub sequence: Option<u64>,
1566    /// Terminal flag: the final chunk of a stream / the final task update.
1567    /// `false` is equivalent to absence and is skipped on encode, so only `true`
1568    /// has protocol meaning. Maps one-to-one onto A2A's `final`.
1569    #[serde(default, skip_serializing_if = "is_false")]
1570    pub last: bool,
1571    /// Why a stream or response ended (OTel finish-reason vocabulary: stop,
1572    /// length, content_filter, tool_call, ...). A string deliberately: that
1573    /// vocabulary belongs to OTel and the providers, not to us.
1574    #[serde(default, skip_serializing_if = "Option::is_none")]
1575    pub finish_reason: Option<String>,
1576    #[serde(default, skip_serializing_if = "Option::is_none")]
1577    pub task_state: Option<TaskState>,
1578    /// OTel `gen_ai.operation.name` value (`chat`, `execute_tool`, ...). On
1579    /// `status` it is the required discriminator (`task`, `card`, `progress`).
1580    #[serde(default, skip_serializing_if = "Option::is_none")]
1581    pub operation: Option<String>,
1582    /// OTel `gen_ai.tool.name`, for tool commands and results.
1583    #[serde(default, skip_serializing_if = "Option::is_none")]
1584    pub tool: Option<String>,
1585    #[serde(default, skip_serializing_if = "Option::is_none")]
1586    pub usage: Option<TokenUsage>,
1587    /// AGDX-native scalar extension context. Never foreign metadata.
1588    #[serde(default, skip_serializing_if = "Option::is_none")]
1589    pub metadata: Option<BTreeMap<String, Value>>,
1590    /// Must-understand marker: a bitset of feature bits a receiver MUST
1591    /// understand to process this message correctly (see [`features`]). A
1592    /// receiver that sees a set bit it does not implement MUST reject or
1593    /// dead-letter the message rather than mis-handle it ([`unmet_requirements`]).
1594    /// `0` (the default, skipped on the wire so pre-marker records stay
1595    /// byte-identical) means "ignore anything you don't understand", the
1596    /// open-world default. This lets one message demand strict handling of a new
1597    /// feature without a whole-envelope version bump (a `must_understand` marker).
1598    ///
1599    /// [`features`]: crate::agent::features
1600    /// [`unmet_requirements`]: AgentEnvelope::unmet_requirements
1601    #[serde(default, skip_serializing_if = "is_zero_u64")]
1602    pub must_understand: u64,
1603    /// The content, codec per the `agdx.ct` header. Default-empty and skipped
1604    /// when empty. The validity matrix says which kinds require it.
1605    #[serde(
1606        default,
1607        skip_serializing_if = "Vec::is_empty",
1608        with = "crate::encoding::bin_bytes"
1609    )]
1610    pub body: Vec<u8>,
1611    /// A detached signature over the canonical encoding of this envelope with the
1612    /// signature field absent, domain-separated by [`SIGNATURE_DOMAIN`]. Opt-in:
1613    /// absent (the open-world default, skipped on the wire so an unsigned record
1614    /// stays byte-identical) means an unsigned record. Verification is SDK-side
1615    /// against a per-agent key registry, so the wire crate stays crypto-free. A
1616    /// signature may ride any kind (no matrix row, like `metadata`).
1617    #[serde(default, skip_serializing_if = "Option::is_none")]
1618    pub signature: Option<Signature>,
1619}
1620
1621fn is_false(value: &bool) -> bool {
1622    !*value
1623}
1624
1625fn is_zero_u64(value: &u64) -> bool {
1626    *value == 0
1627}
1628
1629/// Must-understand feature bits for [`AgentEnvelope::must_understand`]. Each
1630/// constant names one capability a message may demand a receiver implement.
1631/// Defined additively as features land: a newer producer sets a bit an older
1632/// receiver does not know, and that receiver rejects rather than mis-handling
1633/// the message. No bits are defined yet, so today the marker is the mechanism
1634/// in place for the first feature that needs strict handling.
1635pub mod features {
1636    /// A receiver's full understood set is the OR of the bits it implements.
1637    /// With no feature bits defined yet, a current build understands none and
1638    /// only ever sees `must_understand == 0`.
1639    pub const NONE: u64 = 0;
1640}
1641
1642impl AgentEnvelope {
1643    fn base(kind: AgentKind, conversation: ConversationId, source: AgentId) -> Self {
1644        Self {
1645            kind,
1646            record: None,
1647            conversation,
1648            parent: None,
1649            root: None,
1650            source,
1651            target: None,
1652            cause: None,
1653            cause_at: None,
1654            correlation: None,
1655            channel: None,
1656            idempotency_key: None,
1657            deadline_micros: None,
1658            sequence: None,
1659            last: false,
1660            finish_reason: None,
1661            task_state: None,
1662            operation: None,
1663            tool: None,
1664            usage: None,
1665            metadata: None,
1666            must_understand: 0,
1667            body: Vec::new(),
1668            signature: None,
1669        }
1670    }
1671
1672    /// Declare that a receiver MUST understand the feature `bits` to process this
1673    /// message. Bits a receiver lacks make it reject the message
1674    /// ([`unmet_requirements`](Self::unmet_requirements)). Absent bits are
1675    /// ignore-if-unknown. Additive builder method.
1676    #[must_use]
1677    pub fn requiring(mut self, bits: u64) -> Self {
1678        self.must_understand = bits;
1679        self
1680    }
1681
1682    /// The subset of this message's [`must_understand`](Self::must_understand)
1683    /// bits NOT present in the receiver's `understood` set. Non-zero means the
1684    /// receiver cannot safely process the message and must reject or
1685    /// dead-letter it rather than mis-handle a feature it does not implement.
1686    pub fn unmet_requirements(&self, understood: u64) -> u64 {
1687        self.must_understand & !understood
1688    }
1689
1690    /// A `command`: expects a reply or effect, so `correlation` is required.
1691    pub fn command(
1692        record: RecordId,
1693        conversation: ConversationId,
1694        source: AgentId,
1695        correlation: CorrelationId,
1696        body: Vec<u8>,
1697    ) -> Self {
1698        let mut envelope = Self::base(AgentKind::Command, conversation, source);
1699        envelope.record = Some(record);
1700        envelope.correlation = Some(correlation);
1701        envelope.body = body;
1702        envelope
1703    }
1704
1705    /// A `response`: the paired answer to a command (same `correlation`).
1706    pub fn response(
1707        record: RecordId,
1708        conversation: ConversationId,
1709        source: AgentId,
1710        correlation: CorrelationId,
1711        body: Vec<u8>,
1712    ) -> Self {
1713        let mut envelope = Self::base(AgentKind::Response, conversation, source);
1714        envelope.record = Some(record);
1715        envelope.correlation = Some(correlation);
1716        envelope.body = body;
1717        envelope
1718    }
1719
1720    /// An `event`: expects nothing.
1721    pub fn event(
1722        record: RecordId,
1723        conversation: ConversationId,
1724        source: AgentId,
1725        body: Vec<u8>,
1726    ) -> Self {
1727        let mut envelope = Self::base(AgentKind::Event, conversation, source);
1728        envelope.record = Some(record);
1729        envelope.body = body;
1730        envelope
1731    }
1732
1733    /// A `chunk` of the stream `channel`, ordered by `sequence`. Mark the
1734    /// final one with [`terminal`](Self::terminal).
1735    pub fn chunk(
1736        conversation: ConversationId,
1737        source: AgentId,
1738        correlation: CorrelationId,
1739        channel: ChannelId,
1740        sequence: u64,
1741        body: Vec<u8>,
1742    ) -> Self {
1743        let mut envelope = Self::base(AgentKind::Chunk, conversation, source);
1744        envelope.correlation = Some(correlation);
1745        envelope.channel = Some(channel);
1746        envelope.sequence = Some(sequence);
1747        envelope.body = body;
1748        envelope
1749    }
1750
1751    /// A `status` signal discriminated by `operation` (`task`, `card`,
1752    /// `progress`). Task updates additionally require `correlation` and
1753    /// `task_state` (use the with-setters).
1754    pub fn status(
1755        record: RecordId,
1756        conversation: ConversationId,
1757        source: AgentId,
1758        operation: impl Into<String>,
1759    ) -> Self {
1760        let mut envelope = Self::base(AgentKind::Status, conversation, source);
1761        envelope.record = Some(record);
1762        envelope.operation = Some(operation.into());
1763        envelope
1764    }
1765
1766    /// An `error` terminal for `correlation`. `body` is the encoded
1767    /// [`AgentErrorBody`].
1768    pub fn error(
1769        record: RecordId,
1770        conversation: ConversationId,
1771        source: AgentId,
1772        correlation: CorrelationId,
1773        body: Vec<u8>,
1774    ) -> Self {
1775        let mut envelope = Self::base(AgentKind::Error, conversation, source);
1776        envelope.record = Some(record);
1777        envelope.correlation = Some(correlation);
1778        envelope.body = body;
1779        envelope
1780    }
1781
1782    /// Narrow delivery to one agent within a shared topic.
1783    pub fn with_target(mut self, target: AgentId) -> Self {
1784        self.target = Some(target);
1785        self
1786    }
1787
1788    /// Stamp the causal parent: its record id (identity) and, when known, its
1789    /// log position (locator). A handler has both for free from the message it
1790    /// is replying to.
1791    pub fn with_cause(mut self, cause: RecordId, cause_at: Option<LogPosition>) -> Self {
1792        self.cause = Some(cause);
1793        self.cause_at = cause_at;
1794        self
1795    }
1796
1797    /// Pair this message with a correlation id. Required on `command`,
1798    /// `response`, `error`, and `chunk`. Optional on `event` and non-task
1799    /// `status`.
1800    pub fn with_correlation(mut self, correlation: CorrelationId) -> Self {
1801        self.correlation = Some(correlation);
1802        self
1803    }
1804
1805    /// Attach a business idempotency key (commands, responses, events only).
1806    pub fn with_idempotency_key(mut self, key: IdempotencyKey) -> Self {
1807        self.idempotency_key = Some(key);
1808        self
1809    }
1810
1811    /// Declare the drop-dead time (and, on a stream-opening message, the
1812    /// abandonment bound).
1813    pub fn with_deadline_micros(mut self, deadline_micros: u64) -> Self {
1814        self.deadline_micros = Some(deadline_micros);
1815        self
1816    }
1817
1818    /// Mark this message terminal (`last = true`), with the reason the stream
1819    /// or response ended.
1820    pub fn terminal(mut self, finish_reason: impl Into<String>) -> Self {
1821        self.last = true;
1822        self.finish_reason = Some(finish_reason.into());
1823        self
1824    }
1825
1826    /// Attach a task state. Status task updates require it. Responses and
1827    /// errors may carry it as the one-message terminal convenience.
1828    pub fn with_task_state(mut self, state: TaskState) -> Self {
1829        self.task_state = Some(state);
1830        self
1831    }
1832
1833    /// Set the OTel operation name.
1834    pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
1835        self.operation = Some(operation.into());
1836        self
1837    }
1838
1839    /// Set the OTel tool name.
1840    pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
1841        self.tool = Some(tool.into());
1842        self
1843    }
1844
1845    /// Attach token accounting (advisory).
1846    pub fn with_usage(mut self, usage: TokenUsage) -> Self {
1847        self.usage = Some(usage);
1848        self
1849    }
1850
1851    /// Add one AGDX-native metadata entry.
1852    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1853        self.metadata
1854            .get_or_insert_with(BTreeMap::new)
1855            .insert(key.into(), value.into());
1856        self
1857    }
1858
1859    /// Attach a detached [`Signature`] over this envelope. The SDK signs the
1860    /// canonical encoding with the signature absent and domain separator
1861    /// [`SIGNATURE_DOMAIN`], so the field is set last. A signature may ride any
1862    /// kind.
1863    pub fn with_signature(mut self, signature: Signature) -> Self {
1864        self.signature = Some(signature);
1865        self
1866    }
1867}
1868
1869/// A validity-matrix or cap violation. Receivers treat these as protocol
1870/// errors rather than guessing, and the SDK rejects them at publish time.
1871#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1872#[non_exhaustive]
1873pub enum ValidateError {
1874    #[error("{kind} requires `{field}`")]
1875    Missing {
1876        kind: AgentKind,
1877        field: &'static str,
1878    },
1879    #[error("`{field}` is invalid on {kind}")]
1880    Forbidden {
1881        kind: AgentKind,
1882        field: &'static str,
1883    },
1884    #[error("`{field}` is {size}B, exceeds cap {cap}B")]
1885    TooLarge {
1886        field: &'static str,
1887        size: usize,
1888        cap: usize,
1889    },
1890    #[error("`{field}`: {reason}")]
1891    Invalid { field: &'static str, reason: String },
1892}
1893
1894// The per-kind validity matrix, mechanical. R = required, O = optional,
1895// X = invalid:
1896//
1897// | field            | command | response | event | chunk | status | error |
1898// |------------------|---------|----------|-------|-------|--------|-------|
1899// | record           | R       | R        | R     | O     | R      | R     |
1900// | conversation     | R       | R        | R     | R     | R      | R     |
1901// | source           | R       | R        | R     | R     | R      | R     |
1902// | target           | O       | O        | O     | O     | O      | O     |
1903// | cause / cause_at | O       | O        | O     | O     | O      | O     |
1904// | correlation      | R       | R        | O     | R     | O (R task) | R |
1905// | channel          | X       | X        | X     | R     | X      | O     |
1906// | sequence         | X       | X        | X     | R     | X      | O (with channel) |
1907// | last             | X       | X        | X     | O     | O      | X (always terminal) |
1908// | finish_reason    | X       | O        | X     | O (with last) | X | X  |
1909// | idempotency_key  | O       | O        | O     | X     | X      | X     |
1910// | deadline_micros  | O       | X        | X     | O     | R quarantine|unquarantine, X otherwise | X |
1911// | task_state       | X       | O        | X     | X     | R (task|session) | O |
1912// | operation        | O       | O        | O     | R seq 0 (chat|reasoning|tool_args), X after | R (task|session|card|progress|quarantine|unquarantine) | O |
1913// | tool             | O       | O        | O     | O     | X      | O     |
1914// | usage            | X       | O        | O     | O (terminal) | O | O  |
1915// | metadata         | O       | O        | O     | O     | O      | O     |
1916// | body             | R       | R        | R     | R (may be empty with last) | R session, O otherwise | R |
1917/// Check an envelope against the per-kind validity matrix and the caps.
1918pub fn validate(envelope: &AgentEnvelope) -> Result<(), ValidateError> {
1919    use AgentKind::*;
1920    let kind = envelope.kind;
1921
1922    let require = |present: bool, field: &'static str| {
1923        if present {
1924            Ok(())
1925        } else {
1926            Err(ValidateError::Missing { kind, field })
1927        }
1928    };
1929    let forbid = |absent: bool, field: &'static str| {
1930        if absent {
1931            Ok(())
1932        } else {
1933            Err(ValidateError::Forbidden { kind, field })
1934        }
1935    };
1936
1937    // record: required everywhere except chunk (where it is optional).
1938    if kind != Chunk {
1939        require(envelope.record.is_some(), "record")?;
1940    }
1941
1942    if envelope.root.is_some() && envelope.parent.is_none() {
1943        return Err(ValidateError::Invalid {
1944            field: "root",
1945            reason: "root requires parent".to_owned(),
1946        });
1947    }
1948    if envelope.parent == Some(envelope.conversation)
1949        || envelope.root == Some(envelope.conversation)
1950    {
1951        return Err(ValidateError::Invalid {
1952            field: "parent",
1953            reason: "parent and root must differ from conversation".to_owned(),
1954        });
1955    }
1956
1957    // correlation.
1958    match kind {
1959        Command | Response | Chunk | Error => {
1960            require(envelope.correlation.is_some(), "correlation")?
1961        }
1962        Status => {
1963            if envelope.operation.as_deref() == Some(OPERATION_TASK) {
1964                require(envelope.correlation.is_some(), "correlation")?;
1965            }
1966        }
1967        Event => {}
1968    }
1969
1970    // channel + sequence: the chunk identity, allowed on error as a stream
1971    // terminal, sequence only alongside channel.
1972    match kind {
1973        Chunk => {
1974            require(envelope.channel.is_some(), "channel")?;
1975            require(envelope.sequence.is_some(), "sequence")?;
1976        }
1977        Error => {
1978            if envelope.sequence.is_some() && envelope.channel.is_none() {
1979                return Err(ValidateError::Invalid {
1980                    field: "sequence",
1981                    reason: "sequence requires channel".to_owned(),
1982                });
1983            }
1984        }
1985        _ => {
1986            forbid(envelope.channel.is_none(), "channel")?;
1987            forbid(envelope.sequence.is_none(), "sequence")?;
1988        }
1989    }
1990
1991    // last: chunk and status only (error is always terminal, so the flag
1992    // would be redundant noise there).
1993    if envelope.last && !matches!(kind, Chunk | Status) {
1994        return Err(ValidateError::Forbidden {
1995            kind,
1996            field: "last",
1997        });
1998    }
1999
2000    // finish_reason: responses, and terminal chunks.
2001    match kind {
2002        Response => {}
2003        Chunk => {
2004            if envelope.finish_reason.is_some() && !envelope.last {
2005                return Err(ValidateError::Invalid {
2006                    field: "finish_reason",
2007                    reason: "finish_reason rides only the terminal chunk".to_owned(),
2008                });
2009            }
2010        }
2011        _ => forbid(envelope.finish_reason.is_none(), "finish_reason")?,
2012    }
2013
2014    // idempotency_key: business idempotency for command/response/event.
2015    // Chunks and signals carry no dedup semantics.
2016    if matches!(kind, Chunk | Status | Error) {
2017        forbid(envelope.idempotency_key.is_none(), "idempotency_key")?;
2018    }
2019
2020    // deadline_micros: commands, stream-opening chunks (the abandonment bound
2021    // rides sequence 0, and a mid-stream deadline would be ambiguous), and
2022    // registry facts, where it is the signed expiry that bounds a replay.
2023    let registry_fact = kind == Status
2024        && matches!(
2025            envelope.operation.as_deref(),
2026            Some(OPERATION_QUARANTINE | OPERATION_UNQUARANTINE)
2027        );
2028    if registry_fact {
2029        require(envelope.deadline_micros.is_some(), "deadline_micros")?;
2030    } else if matches!(kind, Response | Event | Status | Error) {
2031        forbid(envelope.deadline_micros.is_none(), "deadline_micros")?;
2032    }
2033    if kind == Chunk && envelope.deadline_micros.is_some() && envelope.sequence != Some(0) {
2034        return Err(ValidateError::Invalid {
2035            field: "deadline_micros",
2036            reason: "the stream bound rides the opening chunk (sequence 0)".to_owned(),
2037        });
2038    }
2039
2040    // task_state.
2041    match kind {
2042        Status => {
2043            if matches!(
2044                envelope.operation.as_deref(),
2045                Some(OPERATION_TASK | OPERATION_SESSION)
2046            ) {
2047                require(envelope.task_state.is_some(), "task_state")?;
2048            }
2049        }
2050        Response | Error => {}
2051        _ => forbid(envelope.task_state.is_none(), "task_state")?,
2052    }
2053
2054    // operation: two CLOSED vocabularies (protocol machinery, version-gated
2055    // like AgentKind), open OTel values everywhere else. The status
2056    // discriminator must be task | session | card | progress, and the chunk-stream purpose
2057    // must be chat | reasoning | tool_args and rides ONLY the opening chunk
2058    // (sequence 0), where it is required.
2059    match kind {
2060        Status => {
2061            require(envelope.operation.is_some(), "operation")?;
2062            if let Some(operation) = envelope.operation.as_deref()
2063                && !matches!(
2064                    operation,
2065                    OPERATION_TASK
2066                        | OPERATION_SESSION
2067                        | OPERATION_CARD
2068                        | OPERATION_PROGRESS
2069                        | OPERATION_QUARANTINE
2070                        | OPERATION_UNQUARANTINE
2071                )
2072            {
2073                return Err(ValidateError::Invalid {
2074                    field: "operation",
2075                    reason: format!(
2076                        "status operation must be `{OPERATION_TASK}`, `{OPERATION_SESSION}`, `{OPERATION_CARD}`, \
2077                         `{OPERATION_PROGRESS}`, `{OPERATION_QUARANTINE}`, or \
2078                         `{OPERATION_UNQUARANTINE}`, got `{operation}`"
2079                    ),
2080                });
2081            }
2082        }
2083        Chunk => {
2084            if envelope.sequence == Some(0) {
2085                require(envelope.operation.is_some(), "operation")?;
2086            }
2087            if let Some(operation) = envelope.operation.as_deref() {
2088                if envelope.sequence != Some(0) {
2089                    return Err(ValidateError::Invalid {
2090                        field: "operation",
2091                        reason: "the stream purpose rides the opening chunk (sequence 0)"
2092                            .to_owned(),
2093                    });
2094                }
2095                if !matches!(
2096                    operation,
2097                    OPERATION_CHAT | OPERATION_REASONING | OPERATION_TOOL_ARGS
2098                ) {
2099                    return Err(ValidateError::Invalid {
2100                        field: "operation",
2101                        reason: format!(
2102                            "chunk-stream purpose must be `{OPERATION_CHAT}`, \
2103                             `{OPERATION_REASONING}`, or `{OPERATION_TOOL_ARGS}`, \
2104                             got `{operation}`"
2105                        ),
2106                    });
2107                }
2108            }
2109        }
2110        Command | Response | Event | Error => {}
2111    }
2112
2113    // tool: meaningless on status signals.
2114    if kind == Status {
2115        forbid(envelope.tool.is_none(), "tool")?;
2116    }
2117
2118    // usage: terminal-chunk accounting, never on commands.
2119    match kind {
2120        Command => forbid(envelope.usage.is_none(), "usage")?,
2121        Chunk if envelope.usage.is_some() && !envelope.last => {
2122            return Err(ValidateError::Invalid {
2123                field: "usage",
2124                reason: "whole-stream accounting rides the terminal chunk".to_owned(),
2125            });
2126        }
2127        _ => {}
2128    }
2129
2130    // body.
2131    match kind {
2132        Status if envelope.operation.as_deref() == Some(OPERATION_SESSION) => {
2133            require(!envelope.body.is_empty(), "body")?;
2134            let state = envelope
2135                .task_state
2136                .expect("session state was required above");
2137            if envelope.last != state.is_terminal() {
2138                return Err(ValidateError::Invalid {
2139                    field: "last",
2140                    reason: "session last must match terminal task state".to_owned(),
2141                });
2142            }
2143            // The body shape is checked where the CBOR codec is built in.
2144            #[cfg(feature = "cbor")]
2145            {
2146                let invalid_body = |reason: String| ValidateError::Invalid {
2147                    field: "body",
2148                    reason,
2149                };
2150                let validate_start = |start: &SessionStart| {
2151                    crate::validate::Validate::validate(start)
2152                        .map_err(|error| invalid_body(error.to_string()))?;
2153                    if start.parent != envelope.parent || start.root != envelope.root {
2154                        return Err(invalid_body(
2155                            "session ancestry must match envelope parent and root".to_owned(),
2156                        ));
2157                    }
2158                    Ok(())
2159                };
2160                match state {
2161                    TaskState::Submitted => {
2162                        let start: SessionStart = crate::framing::decode_named(&envelope.body)
2163                            .map_err(|error| invalid_body(error.to_string()))?;
2164                        validate_start(&start)?;
2165                    }
2166                    TaskState::Working => {
2167                        let body: ciborium::value::Value =
2168                            crate::framing::decode_named(&envelope.body)
2169                                .map_err(|error| invalid_body(error.to_string()))?;
2170                        let is_start = matches!(
2171                            body,
2172                            ciborium::value::Value::Map(ref entries)
2173                                if entries.iter().any(|(key, _)| {
2174                                    matches!(key, ciborium::value::Value::Text(name) if name == "agent" || name == "sdk")
2175                                })
2176                        );
2177                        if is_start {
2178                            let start: SessionStart = crate::framing::decode_named(&envelope.body)
2179                                .map_err(|error| invalid_body(error.to_string()))?;
2180                            validate_start(&start)?;
2181                        } else {
2182                            crate::framing::decode_named::<SessionTransition>(&envelope.body)
2183                                .map_err(|error| invalid_body(error.to_string()))?;
2184                        }
2185                    }
2186                    state if state.is_terminal() => {
2187                        crate::framing::decode_named::<SessionEnd>(&envelope.body)
2188                            .map_err(|error| invalid_body(error.to_string()))?;
2189                    }
2190                    _ => {
2191                        crate::framing::decode_named::<SessionTransition>(&envelope.body)
2192                            .map_err(|error| invalid_body(error.to_string()))?;
2193                    }
2194                }
2195            }
2196        }
2197        Status => {}
2198        Chunk => {
2199            if envelope.body.is_empty() && !envelope.last {
2200                return Err(ValidateError::Missing {
2201                    kind,
2202                    field: "body",
2203                });
2204            }
2205        }
2206        _ => require(!envelope.body.is_empty(), "body")?,
2207    }
2208
2209    // Caps. The metadata caps are the load-bearing ones: that field is
2210    // bridge-injected and foreign-influenced, so a hostile edge gets a
2211    // publish-time rejection instead of inflating every record on a topic.
2212    cap_str(envelope.operation.as_deref(), "operation")?;
2213    cap_str(envelope.tool.as_deref(), "tool")?;
2214    cap_str(envelope.finish_reason.as_deref(), "finish_reason")?;
2215    if let Some(metadata) = &envelope.metadata {
2216        if metadata.len() > crate::limits::MAX_METADATA_ENTRIES {
2217            return Err(ValidateError::TooLarge {
2218                field: "metadata",
2219                size: metadata.len(),
2220                cap: crate::limits::MAX_METADATA_ENTRIES,
2221            });
2222        }
2223        let mut total = 0usize;
2224        for (key, value) in metadata {
2225            if key.len() > crate::limits::MAX_METADATA_KEY_BYTES {
2226                return Err(ValidateError::TooLarge {
2227                    field: "metadata key",
2228                    size: key.len(),
2229                    cap: crate::limits::MAX_METADATA_KEY_BYTES,
2230                });
2231            }
2232            let value_size = value_size(value);
2233            if value_size > crate::limits::MAX_METADATA_VALUE_BYTES {
2234                return Err(ValidateError::TooLarge {
2235                    field: "metadata value",
2236                    size: value_size,
2237                    cap: crate::limits::MAX_METADATA_VALUE_BYTES,
2238                });
2239            }
2240            total += key.len() + value_size;
2241        }
2242        if total > crate::limits::MAX_METADATA_TOTAL_BYTES {
2243            return Err(ValidateError::TooLarge {
2244                field: "metadata",
2245                size: total,
2246                cap: crate::limits::MAX_METADATA_TOTAL_BYTES,
2247            });
2248        }
2249    }
2250    // A signature may ride any kind (no matrix row, like metadata). When present
2251    // its capsule must hold its scheme's registered lengths.
2252    if let Some(signature) = &envelope.signature {
2253        signature.validate()?;
2254    }
2255    Ok(())
2256}
2257
2258/// The `status` operation value for task lifecycle updates.
2259pub const OPERATION_TASK: &str = "task";
2260/// The `status` operation value for session lifecycle updates.
2261pub const OPERATION_SESSION: &str = "session";
2262/// The `status` operation value for liveness/capability cards.
2263pub const OPERATION_CARD: &str = "card";
2264/// The `status` operation value for progress ticks.
2265pub const OPERATION_PROGRESS: &str = "progress";
2266/// The `status` operation value for a quarantine fact: an operator marks an agent
2267/// out of routing. The body is the quarantined agent id. Authorized by the
2268/// registry topic's write access control (only an operator may append it), and
2269/// optionally signed for defense in depth.
2270pub const OPERATION_QUARANTINE: &str = "quarantine";
2271/// The `status` operation value for an un-quarantine fact: an operator lifts a
2272/// prior quarantine, returning the agent to routing. The body is the agent id.
2273/// Same authorization as [`OPERATION_QUARANTINE`], so quarantine is not a
2274/// one-way door that only retention expiry can undo.
2275pub const OPERATION_UNQUARANTINE: &str = "unquarantine";
2276
2277// The chunk-stream purpose vocabulary: `operation` on the stream-opening
2278// chunk says what the channel IS, so a consumer reassembling several channels
2279// under one correlation (answer text next to reasoning next to streamed tool
2280// arguments) tells them apart without decoding bodies. Pinned here because
2281// every bridge and viewer would otherwise invent its own spelling.
2282/// The chunk-stream `operation` value for answer/content text (OTel's `chat`).
2283pub const OPERATION_CHAT: &str = "chat";
2284/// The chunk-stream `operation` value for a model's reasoning stream.
2285pub const OPERATION_REASONING: &str = "reasoning";
2286/// The chunk-stream `operation` value for streamed tool-call arguments.
2287pub const OPERATION_TOOL_ARGS: &str = "tool_args";
2288
2289// The state-sync convention: UI/shared state rides `event` envelopes
2290// discriminated by `operation`, never a new kind. Replaying snapshot + deltas
2291// reconstructs the state at any historical offset.
2292/// The `event` operation value for a full state snapshot (the body is the
2293/// state, codec per `agdx.ct`).
2294pub const OPERATION_STATE_SNAPSHOT: &str = "state_snapshot";
2295/// The `event` operation value for a state delta (the body is an RFC 6902
2296/// JSON Patch document).
2297pub const OPERATION_STATE_DELTA: &str = "state_delta";
2298/// The `event` operation value for a work record an agent held while its
2299/// session was paused. The body is a [`SessionParking`].
2300pub const OPERATION_SESSION_PARKED: &str = "session_parked";
2301/// The `event` operation value that records a held work record handled after
2302/// the resume. The body is the [`SessionParking`] of the held record.
2303pub const OPERATION_SESSION_UNPARKED: &str = "session_unparked";
2304
2305// Pinned AGDX-native metadata keys. Values stay strings or scalars per the
2306// metadata rules. The keys are pinned so transcripts, bridges, and projections
2307// agree without per-app conventions.
2308/// Metadata key: the message's chat role. Recommended values: `user`,
2309/// `assistant`, `system`, `tool`. A string because that vocabulary belongs to
2310/// the model providers and the edge protocols, not to us.
2311pub const METADATA_ROLE: &str = "role";
2312/// Metadata key: the bridge hop list, a `Value::List` of bridge id strings.
2313/// A bridge republishing a message appends its own id, and drops a message
2314/// whose hop list already contains it: the loop guard for multi-bridge
2315/// deployments (A2A in, AG-UI out, A2A out again). Bounded by the metadata
2316/// caps like every other entry.
2317pub const METADATA_BRIDGE_HOPS: &str = "bridge_hops";
2318/// Metadata key: the user an agent acts on behalf of. It rides `metadata` (not a
2319/// header) so it falls inside the signed envelope span, so the signer cannot
2320/// forge whom it claims to act for.
2321pub const METADATA_DELEGATED_BY: &str = "on_behalf_of";
2322/// Metadata key: the declared purpose of the operation, a stable input for a
2323/// policy engine at the effect boundary. Advisory unless the envelope is signed.
2324pub const METADATA_PURPOSE: &str = "purpose";
2325/// Metadata key: the declared classification of the data the operation touches.
2326/// Advisory unless the envelope is signed.
2327pub const METADATA_DATA_CLASSIFICATION: &str = "data_classification";
2328/// Metadata key: the task this operation serves. Advisory unless the envelope
2329/// is signed.
2330pub const METADATA_TASK_CONTEXT: &str = "task_context";
2331/// Metadata key: the session's declared intent. Advisory unless the envelope
2332/// is signed.
2333pub const METADATA_SESSION_INTENT: &str = "session_intent";
2334
2335// Crockford base32, the canonical display form of every u128 id (26
2336// characters, the same rendering ULIDs use). Hand-rolled so the crate stays
2337// dependency-free. Generation (entropy and clock) lives SDK-side.
2338const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2339
2340pub(crate) fn crockford_encode(value: u128) -> [u8; 26] {
2341    let mut out = [0u8; 26];
2342    let mut v = value;
2343    for slot in out.iter_mut().rev() {
2344        *slot = CROCKFORD[(v & 0x1f) as usize];
2345        v >>= 5;
2346    }
2347    out
2348}
2349
2350pub(crate) fn crockford_decode(s: &str) -> Result<u128, IdParseError> {
2351    let bytes = s.as_bytes();
2352    if bytes.len() != 26 {
2353        return Err(IdParseError::Length { got: bytes.len() });
2354    }
2355    let mut value: u128 = 0;
2356    for (i, byte) in bytes.iter().enumerate() {
2357        let digit = CROCKFORD
2358            .iter()
2359            .position(|c| *c == byte.to_ascii_uppercase())
2360            .ok_or(IdParseError::Char(*byte as char))?;
2361        // 26 chars carry 130 bits. The top character may only use 3 of its 5
2362        // (the ULID overflow rule), so a first digit past 7 cannot fit u128.
2363        if i == 0 && digit > 7 {
2364            return Err(IdParseError::Overflow);
2365        }
2366        value = (value << 5) | digit as u128;
2367    }
2368    Ok(value)
2369}
2370
2371fn cap_str(value: Option<&str>, field: &'static str) -> Result<(), ValidateError> {
2372    if let Some(value) = value
2373        && value.len() > crate::limits::MAX_AGENT_STRING_BYTES
2374    {
2375        return Err(ValidateError::TooLarge {
2376            field,
2377            size: value.len(),
2378            cap: crate::limits::MAX_AGENT_STRING_BYTES,
2379        });
2380    }
2381    Ok(())
2382}
2383
2384// Approximate scalar size in bytes for the metadata caps: text by length,
2385// scalars by their widest encoding, lists by the sum of their elements.
2386fn value_size(value: &Value) -> usize {
2387    match value {
2388        Value::Str(s) => s.len(),
2389        Value::List(items) => items.iter().map(|item| 1 + value_size(item)).sum(),
2390        _ => 9,
2391    }
2392}
2393
2394#[cfg(test)]
2395mod tests {
2396    use super::*;
2397    use crate::validate::Validate;
2398
2399    fn session_start() -> SessionStart {
2400        SessionStart {
2401            label: Some("Research".to_owned()),
2402            namespace: Some("agents".to_owned()),
2403            agent: "planner".parse().expect("valid agent id"),
2404            sdk: SdkInfo {
2405                language: "rust".to_owned(),
2406                version: "0.8.0".to_owned(),
2407            },
2408            parent: None,
2409            root: None,
2410            idle_timeout_micros: Some(300_000_000),
2411            budget: Some(Budget {
2412                tokens: Some(1000),
2413                cost_micros: None,
2414            }),
2415            tags: vec!["demo".to_owned()],
2416        }
2417    }
2418
2419    #[test]
2420    fn given_session_status_when_decoded_then_should_keep_known_and_unknown_values() {
2421        assert_eq!(
2422            serde_json::from_str::<SessionStatus>("\"paused\"").expect("paused"),
2423            SessionStatus::Paused
2424        );
2425        assert_eq!(
2426            serde_json::from_str::<SessionStatus>("\"future\"").expect("unknown"),
2427            SessionStatus::Unrecognized
2428        );
2429    }
2430
2431    #[test]
2432    fn given_session_start_when_validated_then_should_enforce_label_and_tag_caps() {
2433        let mut start = session_start();
2434        start.validate().expect("valid start");
2435        start.label = Some("bad\nlabel".to_owned());
2436        assert!(start.validate().is_err());
2437        start.label = Some("x".repeat(crate::limits::MAX_SESSION_LABEL_BYTES + 1));
2438        assert!(start.validate().is_err());
2439        start.label = None;
2440        start.tags = vec!["tag".to_owned(); crate::limits::MAX_MEMORY_TAGS + 1];
2441        assert!(start.validate().is_err());
2442    }
2443
2444    #[cfg(feature = "cbor")]
2445    #[test]
2446    fn given_session_status_bodies_when_validated_then_should_match_the_lifecycle() {
2447        let mut status = AgentEnvelope::status(
2448            RecordId::from_u128(1),
2449            ConversationId::from_u128(2),
2450            "planner".parse().expect("valid agent id"),
2451            OPERATION_SESSION,
2452        );
2453        status.task_state = Some(TaskState::Submitted);
2454        status.body = crate::framing::encode_named(&session_start()).expect("start encodes");
2455        validate(&status).expect("submitted start");
2456        status.parent = Some(ConversationId::from_u128(8));
2457        assert!(
2458            validate(&status).is_err(),
2459            "start and envelope ancestry must match"
2460        );
2461        status.parent = None;
2462
2463        status.task_state = Some(TaskState::Paused);
2464        status.body = crate::framing::encode_named(&SessionTransition::default())
2465            .expect("transition encodes");
2466        validate(&status).expect("paused transition");
2467
2468        status.task_state = Some(TaskState::Completed);
2469        status.body = crate::framing::encode_named(&SessionEnd::default()).expect("end encodes");
2470        assert!(validate(&status).is_err(), "terminal status needs last");
2471        status.last = true;
2472        validate(&status).expect("completed end");
2473    }
2474
2475    #[cfg(feature = "cbor")]
2476    #[test]
2477    fn given_a_parked_record_when_encoded_then_should_round_trip_and_require_a_generation() {
2478        use crate::validate::Validate;
2479        let source = |generation| crate::graph::SourceRef::Message {
2480            stream: 1,
2481            topic: 2,
2482            partition: 3,
2483            offset: 4,
2484            generation,
2485            conversation: Some(ConversationId::from_u128(5).to_string()),
2486        };
2487        let parking = SessionParking {
2488            source: source(Some(6)),
2489            role: "worker".parse().expect("valid agent id"),
2490            request: LogPosition::new(1, 7, 0, 8),
2491        };
2492        parking.validate().expect("a complete parking is valid");
2493        let bytes = crate::framing::encode_named(&parking).expect("parking encodes");
2494        let decoded: SessionParking = crate::framing::decode_named(&bytes).expect("decodes");
2495        assert_eq!(decoded, parking);
2496        let unproven = SessionParking {
2497            source: source(None),
2498            ..parking.clone()
2499        };
2500        assert!(unproven.validate().is_err());
2501        let not_a_record = SessionParking {
2502            source: crate::graph::SourceRef::Memory { id: "m".to_owned() },
2503            ..parking
2504        };
2505        assert!(not_a_record.validate().is_err());
2506    }
2507
2508    #[test]
2509    fn given_a_pause_request_body_when_decoded_then_should_read_an_empty_body_as_no_participants() {
2510        let empty: SessionPauseRequest = serde_json::from_slice(b"{}").expect("empty decodes");
2511        assert!(empty.participants.is_empty());
2512        let named = SessionPauseRequest {
2513            participants: vec!["worker".parse().expect("valid agent id")],
2514        };
2515        let json = serde_json::to_string(&named).expect("request encodes");
2516        assert_eq!(json, r#"{"participants":["worker"]}"#);
2517        assert_eq!(
2518            serde_json::from_str::<SessionPauseRequest>(&json).expect("decodes"),
2519            named
2520        );
2521    }
2522
2523    #[test]
2524    fn given_an_id_when_displayed_then_should_round_trip_through_crockford_base32() {
2525        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2526        let text = id.to_string();
2527        assert_eq!(text.len(), 26);
2528        assert_eq!(text.parse::<RecordId>().expect("parses"), id);
2529        // Lowercase parses too (Crockford is case-insensitive).
2530        assert_eq!(text.to_lowercase().parse::<RecordId>().expect("parses"), id);
2531        assert_eq!(
2532            RecordId::from_u128(0).to_string(),
2533            "00000000000000000000000000"
2534        );
2535        assert_eq!(
2536            RecordId::from_u128(u128::MAX).to_string(),
2537            "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"
2538        );
2539    }
2540
2541    #[test]
2542    fn given_an_id_in_json_when_encoded_then_should_use_readable_text() {
2543        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2544        let json = serde_json::to_string(&id).expect("serializes");
2545        assert_eq!(json, format!("\"{id}\""));
2546        assert_eq!(
2547            serde_json::from_str::<RecordId>(&json).expect("deserializes"),
2548            id
2549        );
2550        let byte_array = serde_json::to_string(&[0_u8; 16]).expect("serializes byte array");
2551        assert!(serde_json::from_str::<RecordId>(&byte_array).is_err());
2552    }
2553
2554    #[cfg(feature = "cbor")]
2555    #[test]
2556    fn given_an_id_in_named_cbor_when_encoded_then_should_use_fixed_width_bytes() {
2557        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2558        let encoded = crate::framing::encode_named(&id).expect("serializes");
2559        let decoded = crate::framing::decode_named::<RecordId>(&encoded).expect("deserializes");
2560        assert_eq!(decoded, id);
2561    }
2562
2563    #[test]
2564    fn given_invalid_id_strings_when_parsed_then_should_reject_with_the_right_error() {
2565        assert_eq!(
2566            "short".parse::<RecordId>(),
2567            Err(IdParseError::Length { got: 5 })
2568        );
2569        assert_eq!(
2570            "8ZZZZZZZZZZZZZZZZZZZZZZZZZ".parse::<RecordId>(),
2571            Err(IdParseError::Overflow)
2572        );
2573        assert!(matches!(
2574            "UUUUUUUUUUUUUUUUUUUUUUUUUU".parse::<RecordId>(),
2575            Err(IdParseError::Char('U'))
2576        ));
2577    }
2578
2579    #[test]
2580    fn given_agent_id_strings_when_parsed_then_should_accept_printable_and_reject_control() {
2581        for s in ["planner", "planner@acme.example", "team/planner", "a:b"] {
2582            assert_eq!(
2583                s.parse::<AgentId>()
2584                    .expect("a printable agent id is valid")
2585                    .as_str(),
2586                s
2587            );
2588        }
2589        assert!("".parse::<AgentId>().is_err());
2590        assert!("bad\nid".parse::<AgentId>().is_err());
2591    }
2592
2593    #[test]
2594    fn given_task_state_codes_when_mapped_then_should_match_the_pinned_dictionary() {
2595        let expected = [
2596            (TaskState::Submitted, 1u8),
2597            (TaskState::Working, 2),
2598            (TaskState::InputRequired, 3),
2599            (TaskState::Completed, 4),
2600            (TaskState::Canceled, 5),
2601            (TaskState::Failed, 6),
2602            (TaskState::Rejected, 7),
2603            (TaskState::AuthRequired, 8),
2604            (TaskState::Unknown, 9),
2605            (TaskState::Paused, 10),
2606        ];
2607        for (state, code) in expected {
2608            assert_eq!(state.code(), code);
2609            assert_eq!(TaskState::from_code(code), state);
2610        }
2611        // An unknown code decodes and passes through as non-terminal.
2612        let future = TaskState::from_code(42);
2613        assert_eq!(future, TaskState::Unrecognized(42));
2614        assert_eq!(future.code(), 42);
2615        assert!(!future.is_terminal());
2616        assert!(TaskState::Completed.is_terminal());
2617        assert!(!TaskState::Working.is_terminal());
2618        assert!(!TaskState::Paused.is_terminal());
2619        assert_eq!(TaskState::Paused.to_string(), "paused");
2620        assert_eq!("paused".parse::<TaskState>(), Ok(TaskState::Paused));
2621    }
2622
2623    #[test]
2624    fn given_task_state_names_when_round_tripped_then_should_match_the_a2a_vocabulary() {
2625        assert_eq!(TaskState::InputRequired.to_string(), "input-required");
2626        assert_eq!(
2627            "auth-required".parse::<TaskState>().expect("parses"),
2628            TaskState::AuthRequired
2629        );
2630        assert!("nope".parse::<TaskState>().is_err());
2631    }
2632
2633    #[test]
2634    fn given_error_and_dead_letter_codes_when_mapped_then_should_match_the_dictionaries() {
2635        assert_eq!(AgentErrorCode::InvalidRequest.code(), 1);
2636        assert_eq!(AgentErrorCode::Internal.code(), 7);
2637        assert_eq!(
2638            AgentErrorCode::from_code(99),
2639            AgentErrorCode::Unrecognized(99)
2640        );
2641        assert_eq!(DeadLetterReason::RetryExhausted.code(), 1);
2642        assert_eq!(DeadLetterReason::DeadlineExceeded.code(), 4);
2643        assert_eq!(
2644            DeadLetterReason::from_code(77),
2645            DeadLetterReason::Unrecognized(77)
2646        );
2647    }
2648
2649    #[test]
2650    fn given_every_u8_when_mapped_through_the_dictionaries_then_the_code_should_round_trip() {
2651        // Totality: from_code is defined on the whole u8 space (unknown codes
2652        // become Unrecognized), and code() is its left inverse, so no byte can
2653        // panic or be lost on decode.
2654        for code in 0u8..=u8::MAX {
2655            assert_eq!(TaskState::from_code(code).code(), code);
2656            assert_eq!(AgentErrorCode::from_code(code).code(), code);
2657            assert_eq!(DeadLetterReason::from_code(code).code(), code);
2658        }
2659    }
2660
2661    #[test]
2662    fn given_an_idempotency_key_when_validated_then_should_enforce_the_cap() {
2663        assert!("job-123-attempt-2".parse::<IdempotencyKey>().is_ok());
2664        assert!("".parse::<IdempotencyKey>().is_err());
2665        assert!("x".repeat(65).parse::<IdempotencyKey>().is_err());
2666    }
2667
2668    #[test]
2669    fn given_an_idempotency_key_with_a_control_character_when_parsed_then_should_refuse_it() {
2670        assert!("job-123\nattempt-2".parse::<IdempotencyKey>().is_err());
2671        assert!("job-123\u{7f}".parse::<IdempotencyKey>().is_err());
2672        assert!(
2673            serde_json::from_str::<IdempotencyKey>("\"job\\u0000\"").is_err(),
2674            "a decoded key is checked too"
2675        );
2676    }
2677
2678    #[test]
2679    fn given_a_command_when_validated_then_should_pass_and_enforce_the_matrix() {
2680        let (record, conversation, source, correlation) = ids();
2681        let command =
2682            AgentEnvelope::command(record, conversation, source, correlation, b"do".to_vec());
2683        validate(&command).expect("a well-formed command validates");
2684
2685        // A command without correlation is an event wearing the wrong kind.
2686        let mut missing = command.clone();
2687        missing.correlation = None;
2688        assert_eq!(
2689            validate(&missing),
2690            Err(ValidateError::Missing {
2691                kind: AgentKind::Command,
2692                field: "correlation"
2693            })
2694        );
2695
2696        // usage is X on commands (accounting rides replies and terminals).
2697        let with_usage = command.clone().with_usage(TokenUsage::default());
2698        assert!(matches!(
2699            validate(&with_usage),
2700            Err(ValidateError::Forbidden { field: "usage", .. })
2701        ));
2702
2703        // channel is chunk identity, invalid elsewhere.
2704        let mut with_channel = command;
2705        with_channel.channel = Some(ChannelId::from_u128(1));
2706        assert!(matches!(
2707            validate(&with_channel),
2708            Err(ValidateError::Forbidden {
2709                field: "channel",
2710                ..
2711            })
2712        ));
2713    }
2714
2715    #[test]
2716    fn given_chunks_when_validated_then_should_enforce_stream_semantics() {
2717        let (_, conversation, source, correlation) = ids();
2718        let channel = ChannelId::from_u128(23);
2719        let chunk = AgentEnvelope::chunk(
2720            conversation,
2721            source.clone(),
2722            correlation,
2723            channel,
2724            0,
2725            b"tok".to_vec(),
2726        )
2727        .with_operation(OPERATION_CHAT);
2728        validate(&chunk).expect("a stream chunk validates");
2729
2730        // The opening chunk REQUIRES its purpose, from the pinned vocabulary.
2731        let mut undeclared = chunk.clone();
2732        undeclared.operation = None;
2733        assert!(matches!(
2734            validate(&undeclared),
2735            Err(ValidateError::Missing {
2736                field: "operation",
2737                ..
2738            })
2739        ));
2740        let mut off_vocabulary = chunk.clone();
2741        off_vocabulary.operation = Some("telemetry".to_owned());
2742        assert!(matches!(
2743            validate(&off_vocabulary),
2744            Err(ValidateError::Invalid {
2745                field: "operation",
2746                ..
2747            })
2748        ));
2749
2750        // The purpose rides ONLY the opening chunk.
2751        let redeclared = AgentEnvelope::chunk(
2752            conversation,
2753            source.clone(),
2754            correlation,
2755            channel,
2756            3,
2757            b"tok".to_vec(),
2758        )
2759        .with_operation(OPERATION_REASONING);
2760        assert!(matches!(
2761            validate(&redeclared),
2762            Err(ValidateError::Invalid {
2763                field: "operation",
2764                ..
2765            })
2766        ));
2767
2768        // The terminal chunk may be empty and carries finish_reason + usage.
2769        let terminal = AgentEnvelope::chunk(
2770            conversation,
2771            source.clone(),
2772            correlation,
2773            channel,
2774            41,
2775            Vec::new(),
2776        )
2777        .terminal("stop")
2778        .with_usage(TokenUsage {
2779            input_tokens: 100,
2780            output_tokens: 42,
2781            ..Default::default()
2782        });
2783        validate(&terminal).expect("a terminal chunk validates");
2784
2785        // A non-terminal chunk cannot carry finish_reason or usage.
2786        let mut early_finish = chunk.clone();
2787        early_finish.finish_reason = Some("stop".to_owned());
2788        assert!(matches!(
2789            validate(&early_finish),
2790            Err(ValidateError::Invalid {
2791                field: "finish_reason",
2792                ..
2793            })
2794        ));
2795
2796        // An empty non-terminal chunk carries nothing.
2797        let empty = AgentEnvelope::chunk(
2798            conversation,
2799            source.clone(),
2800            correlation,
2801            channel,
2802            1,
2803            Vec::new(),
2804        );
2805        assert!(matches!(
2806            validate(&empty),
2807            Err(ValidateError::Missing { field: "body", .. })
2808        ));
2809
2810        // Chunks carry no dedup semantics.
2811        let mut keyed = chunk.clone();
2812        keyed.idempotency_key = Some("k".parse().expect("valid key"));
2813        assert!(matches!(
2814            validate(&keyed),
2815            Err(ValidateError::Forbidden {
2816                field: "idempotency_key",
2817                ..
2818            })
2819        ));
2820
2821        // The abandonment bound rides only the opening chunk (sequence 0).
2822        let opening = chunk.with_deadline_micros(1);
2823        validate(&opening).expect("an opening chunk may declare the bound");
2824        let late = AgentEnvelope::chunk(
2825            conversation,
2826            source.clone(),
2827            correlation,
2828            channel,
2829            5,
2830            b"tok".to_vec(),
2831        )
2832        .with_deadline_micros(1);
2833        assert!(matches!(
2834            validate(&late),
2835            Err(ValidateError::Invalid {
2836                field: "deadline_micros",
2837                ..
2838            })
2839        ));
2840    }
2841
2842    #[test]
2843    fn given_status_signals_when_validated_then_task_updates_should_require_state() {
2844        let (record, conversation, source, correlation) = ids();
2845        // A liveness card: no correlation, no task_state.
2846        let card = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_CARD);
2847        validate(&card).expect("a card validates");
2848
2849        // A task update requires correlation + task_state.
2850        let bare_task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK);
2851        assert!(matches!(
2852            validate(&bare_task),
2853            Err(ValidateError::Missing {
2854                field: "correlation",
2855                ..
2856            })
2857        ));
2858        let task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK)
2859            .with_correlation(correlation)
2860            .with_task_state(TaskState::Working);
2861        validate(&task).expect("a task update validates");
2862
2863        // The discriminator is a CLOSED vocabulary: task | card | progress.
2864        let off_vocabulary =
2865            AgentEnvelope::status(record, conversation, source.clone(), "telemetry");
2866        assert!(matches!(
2867            validate(&off_vocabulary),
2868            Err(ValidateError::Invalid {
2869                field: "operation",
2870                ..
2871            })
2872        ));
2873    }
2874
2875    #[test]
2876    fn given_registry_facts_when_validated_then_should_require_the_signed_deadline() {
2877        let (record, conversation, source, _) = ids();
2878        for operation in [OPERATION_QUARANTINE, OPERATION_UNQUARANTINE] {
2879            let mut fact = AgentEnvelope::status(record, conversation, source.clone(), operation);
2880            fact.body = b"worker".to_vec();
2881            assert!(matches!(
2882                validate(&fact),
2883                Err(ValidateError::Missing {
2884                    field: "deadline_micros",
2885                    ..
2886                })
2887            ));
2888            validate(&fact.with_deadline_micros(1)).expect("a fact with its deadline validates");
2889        }
2890        let card = AgentEnvelope::status(record, conversation, source, OPERATION_CARD)
2891            .with_deadline_micros(1);
2892        assert!(matches!(
2893            validate(&card),
2894            Err(ValidateError::Forbidden {
2895                field: "deadline_micros",
2896                ..
2897            })
2898        ));
2899    }
2900
2901    #[test]
2902    fn given_an_error_when_validated_then_last_should_be_forbidden() {
2903        let (record, conversation, source, correlation) = ids();
2904        let error =
2905            AgentEnvelope::error(record, conversation, source, correlation, b"boom".to_vec());
2906        validate(&error).expect("an error validates");
2907
2908        // error is ALWAYS terminal, so the flag would be redundant.
2909        let mut flagged = error.clone();
2910        flagged.last = true;
2911        assert!(matches!(
2912            validate(&flagged),
2913            Err(ValidateError::Forbidden { field: "last", .. })
2914        ));
2915
2916        // sequence without channel is incoherent.
2917        let mut dangling = error;
2918        dangling.sequence = Some(3);
2919        assert!(matches!(
2920            validate(&dangling),
2921            Err(ValidateError::Invalid {
2922                field: "sequence",
2923                ..
2924            })
2925        ));
2926    }
2927
2928    fn descriptor(skill_id: &str) -> CapabilityDescriptor {
2929        CapabilityDescriptor {
2930            skill_id: skill_id.to_owned(),
2931            input: None,
2932            output: None,
2933            cost_class: None,
2934            latency_class: None,
2935            max_concurrency: None,
2936            health: None,
2937            load: None,
2938        }
2939    }
2940
2941    #[test]
2942    fn given_an_agent_card_when_validated_then_should_enforce_the_caps() {
2943        let card = AgentCard {
2944            name: Some("rollout-planner".to_owned()),
2945            version: Some("1.4.2".to_owned()),
2946            capabilities: vec![
2947                CapabilityDescriptor {
2948                    skill_id: "chat".to_owned(),
2949                    input: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2950                    output: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2951                    cost_class: Some(2),
2952                    latency_class: Some(1),
2953                    max_concurrency: Some(8),
2954                    health: Some(Health::Healthy),
2955                    load: Some(250),
2956                },
2957                descriptor("plan_rollout"),
2958            ],
2959            ttl_micros: Some(30_000_000),
2960            inbox: Some("rollout-planner.work".to_owned()),
2961        };
2962        card.validate().expect("a well-formed card validates");
2963
2964        let mut crowded = card.clone();
2965        crowded.capabilities = vec![descriptor("x"); crate::limits::MAX_CARD_CAPABILITIES + 1];
2966        assert!(matches!(
2967            crowded.validate(),
2968            Err(ValidateError::TooLarge {
2969                field: "capabilities",
2970                ..
2971            })
2972        ));
2973
2974        let mut oversized = card.clone();
2975        oversized.name = Some("n".repeat(crate::limits::MAX_AGENT_STRING_BYTES + 1));
2976        assert!(matches!(
2977            oversized.validate(),
2978            Err(ValidateError::TooLarge { field: "name", .. })
2979        ));
2980
2981        let mut long_inbox = card;
2982        long_inbox.inbox = Some("i".repeat(crate::limits::MAX_AGENT_STRING_BYTES + 1));
2983        assert!(matches!(
2984            long_inbox.validate(),
2985            Err(ValidateError::TooLarge { field: "inbox", .. })
2986        ));
2987    }
2988
2989    #[cfg(feature = "cbor")]
2990    #[test]
2991    fn given_a_schema_id_named_like_a_content_type_when_round_tripped_then_should_stay_a_schema_id()
2992    {
2993        use crate::framing::{decode_named, encode_named};
2994        // The tag disambiguates a schema id that happens to be a content-type
2995        // name: an untagged encoding would decode this as ContentType::Json.
2996        let reference = ContentRef::SchemaId("json".to_owned());
2997        let bytes = encode_named(&reference).expect("encodes");
2998        let back: ContentRef = decode_named(&bytes).expect("decodes");
2999        assert_eq!(back, ContentRef::SchemaId("json".to_owned()));
3000        // A genuine content-type still round-trips as one.
3001        let ct = ContentRef::ContentType(crate::content::ContentType::Json);
3002        let back: ContentRef = decode_named(&encode_named(&ct).expect("encodes")).expect("decodes");
3003        assert_eq!(back, ct);
3004    }
3005
3006    #[test]
3007    fn given_a_signature_when_validated_then_should_enforce_per_scheme_lengths() {
3008        let valid = Signature {
3009            scheme: SIGNATURE_SCHEME_ED25519,
3010            key_id: vec![1u8; 8],
3011            bytes: vec![2u8; 64],
3012            context: None,
3013        };
3014        valid
3015            .validate()
3016            .expect("a well-formed Ed25519 signature validates");
3017
3018        let mut short_key = valid.clone();
3019        short_key.key_id = vec![1u8; 4];
3020        assert!(matches!(
3021            short_key.validate(),
3022            Err(ValidateError::Invalid {
3023                field: "key_id",
3024                ..
3025            })
3026        ));
3027
3028        let mut short_signature = valid.clone();
3029        short_signature.bytes = vec![2u8; 32];
3030        assert!(matches!(
3031            short_signature.validate(),
3032            Err(ValidateError::Invalid { field: "bytes", .. })
3033        ));
3034
3035        // Unknown scheme codes pass through (the open-dictionary rule).
3036        // Verification is SDK-side and scheme-aware.
3037        let future = Signature {
3038            scheme: 42,
3039            key_id: vec![1u8; 3],
3040            bytes: vec![2u8; 99],
3041            context: None,
3042        };
3043        future.validate().expect("an unknown scheme flows through");
3044    }
3045
3046    #[test]
3047    fn given_a_body_ref_when_validated_then_should_enforce_reference_and_digest() {
3048        let valid = BodyRef::new("s3://transcripts/conv-1/msg-9", 4_194_304, [7u8; 32]);
3049        valid.validate().expect("a well-formed reference validates");
3050
3051        let mut empty = valid.clone();
3052        empty.reference = String::new();
3053        assert!(matches!(
3054            empty.validate(),
3055            Err(ValidateError::Invalid {
3056                field: "reference",
3057                ..
3058            })
3059        ));
3060
3061        let mut oversized = valid.clone();
3062        oversized.reference = "x".repeat(crate::limits::MAX_BODY_REFERENCE_BYTES + 1);
3063        assert!(matches!(
3064            oversized.validate(),
3065            Err(ValidateError::TooLarge {
3066                field: "reference",
3067                ..
3068            })
3069        ));
3070
3071        let mut truncated = valid;
3072        truncated.sha256 = vec![7u8; 16];
3073        assert!(matches!(
3074            truncated.validate(),
3075            Err(ValidateError::Invalid {
3076                field: "sha256",
3077                ..
3078            })
3079        ));
3080    }
3081
3082    #[test]
3083    fn given_oversized_metadata_when_validated_then_should_reject() {
3084        let (record, conversation, source, correlation) = ids();
3085        let mut command =
3086            AgentEnvelope::command(record, conversation, source, correlation, b"x".to_vec());
3087        for i in 0..crate::limits::MAX_METADATA_ENTRIES + 1 {
3088            command = command.with_metadata(format!("k{i}"), i as i64);
3089        }
3090        assert!(matches!(
3091            validate(&command),
3092            Err(ValidateError::TooLarge {
3093                field: "metadata",
3094                ..
3095            })
3096        ));
3097    }
3098
3099    fn ids() -> (RecordId, ConversationId, AgentId, CorrelationId) {
3100        (
3101            RecordId::from_u128(7),
3102            ConversationId::from_u128(11),
3103            "test-agent".parse().expect("valid agent id"),
3104            CorrelationId::from_u128(17),
3105        )
3106    }
3107}
3108
3109#[cfg(all(test, feature = "cbor"))]
3110mod wire_tests {
3111    use super::*;
3112    use crate::framing::{decode_named, encode_named};
3113
3114    #[test]
3115    fn given_a_wrong_length_locator_when_decoded_then_should_error_not_panic() {
3116        // CBOR byte string: 0x40 | len for lengths below 24. Exactly 20 bytes
3117        // is the only valid locator. Anything else is a clean decode error.
3118        let mut valid = vec![0x40 | 20];
3119        valid.extend_from_slice(&[0u8; 20]);
3120        decode_named::<LogPosition>(&valid).expect("20 packed bytes decode");
3121
3122        for bad_len in [0u8, 19, 21, 23] {
3123            let mut bytes = vec![0x40 | bad_len];
3124            bytes.extend_from_slice(&vec![0u8; bad_len as usize]);
3125            assert!(
3126                decode_named::<LogPosition>(&bytes).is_err(),
3127                "a {bad_len}-byte locator must error, not panic"
3128            );
3129        }
3130    }
3131
3132    #[test]
3133    fn given_a_locator_when_round_tripped_then_should_preserve_every_field() {
3134        let pos = LogPosition::new(0x0102_0304, 0x0506_0708, 0x090A_0B0C, 0x0D0E_0F10_1112_1314);
3135        let bytes = encode_named(&pos).expect("encodes");
3136        let back: LogPosition = decode_named(&bytes).expect("decodes");
3137        assert_eq!(back, pos);
3138    }
3139
3140    #[test]
3141    fn given_an_envelope_when_round_tripped_then_should_preserve_every_field() {
3142        let envelope = AgentEnvelope::command(
3143            RecordId::from_u128(1),
3144            ConversationId::from_u128(2),
3145            "source-agent".parse().expect("valid agent id"),
3146            CorrelationId::from_u128(4),
3147            b"payload".to_vec(),
3148        )
3149        .with_target("target-agent".parse().expect("valid agent id"))
3150        .with_cause(RecordId::from_u128(6), Some(LogPosition::new(1, 2, 3, 44)))
3151        .with_idempotency_key("job-1".parse().expect("valid key"))
3152        .with_deadline_micros(1_700_000_000_000_000)
3153        .with_operation("chat")
3154        .with_tool("search")
3155        .with_metadata("pool", "eu-west");
3156        let bytes = encode_named(&envelope).expect("encodes");
3157        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3158        assert_eq!(back, envelope);
3159    }
3160
3161    #[test]
3162    fn given_a_must_understand_marker_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
3163        // A receiver lacking a required bit must see it as unmet, and the
3164        // default-zero marker is omitted on the wire so pre-marker records stay
3165        // byte-identical.
3166        let envelope = AgentEnvelope::event(
3167            RecordId::from_u128(1),
3168            ConversationId::from_u128(2),
3169            "source-agent".parse().expect("valid agent id"),
3170            b"e".to_vec(),
3171        )
3172        .requiring(0b101);
3173        let bytes = encode_named(&envelope).expect("encodes");
3174        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3175        assert_eq!(back.must_understand, 0b101);
3176        // A receiver that understands bit 0 but not bit 2 has bit 2 unmet.
3177        assert_eq!(back.unmet_requirements(0b001), 0b100);
3178        // A receiver that understands both has nothing unmet.
3179        assert_eq!(back.unmet_requirements(0b111), 0);
3180        // The zero marker (open-world default) is unmet by nobody and omitted.
3181        let plain = AgentEnvelope::event(
3182            RecordId::from_u128(1),
3183            ConversationId::from_u128(2),
3184            "source-agent".parse().expect("valid agent id"),
3185            b"e".to_vec(),
3186        );
3187        assert_eq!(plain.unmet_requirements(features::NONE), 0);
3188        let json = serde_json::to_string(&plain).expect("json");
3189        assert!(
3190            !json.contains("must_understand"),
3191            "zero marker must be omitted: {json}"
3192        );
3193    }
3194
3195    #[test]
3196    fn given_absent_options_when_encoded_then_should_cost_zero_bytes() {
3197        // The skip-serializing discipline: a minimal event encodes only its
3198        // five present fields (kind, record, conversation, source, body).
3199        let envelope = AgentEnvelope::event(
3200            RecordId::from_u128(1),
3201            ConversationId::from_u128(2),
3202            "source-agent".parse().expect("valid agent id"),
3203            b"e".to_vec(),
3204        );
3205        let bytes = encode_named(&envelope).expect("encodes");
3206        // A CBOR map with at most 15 fields encodes its count in the head byte's
3207        // low nibble (major type 5, `0xa0 | count`).
3208        assert_eq!(bytes[0] & 0x0f, 5, "absent optionals must not be encoded");
3209        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3210        assert!(!back.last);
3211        assert!(back.metadata.is_none());
3212    }
3213
3214    #[test]
3215    fn given_an_id_when_encoded_then_should_ride_as_one_fixed_width_byte_string() {
3216        let bytes = encode_named(&RecordId::from_u128(0x0102)).expect("encodes");
3217        // CBOR byte-string head `0x50` (major type 2, length 16), then the 16
3218        // big-endian bytes, fixed width, no bignum tag. 17 bytes total.
3219        assert_eq!(bytes.len(), 17);
3220        assert_eq!(bytes[0], 0x50);
3221        assert_eq!(bytes[16], 0x02);
3222        assert_eq!(bytes[15], 0x01);
3223    }
3224
3225    #[test]
3226    fn given_a_task_state_when_encoded_then_should_ride_as_a_bare_u8() {
3227        let bytes = encode_named(&TaskState::Completed).expect("encodes");
3228        assert_eq!(bytes, vec![4]);
3229        let unknown = encode_named(&42u8).expect("encodes a raw code");
3230        let back: TaskState = decode_named(&unknown).expect("unknown code decodes");
3231        assert_eq!(back, TaskState::Unrecognized(42));
3232    }
3233
3234    #[test]
3235    fn given_an_error_body_when_round_tripped_then_should_preserve_the_dictionary_code() {
3236        let body = AgentErrorBody {
3237            code: AgentErrorCode::ToolFailure,
3238            message: Some("search timed out".to_owned()),
3239            retryable: true,
3240            detail: Some(BTreeMap::from([("attempt".to_owned(), Value::Int(3))])),
3241        };
3242        let bytes = encode_named(&body).expect("encodes");
3243        let back: AgentErrorBody = decode_named(&bytes).expect("decodes");
3244        assert_eq!(back, body);
3245    }
3246
3247    #[test]
3248    fn given_a_body_ref_when_round_tripped_then_should_preserve_the_digest_as_a_byte_string() {
3249        let capsule = BodyRef::new("kv://bodies/abc", 1024, [9u8; 32]);
3250        let bytes = encode_named(&capsule).expect("encodes");
3251        let back: BodyRef = decode_named(&bytes).expect("decodes");
3252        assert_eq!(back, capsule);
3253        assert!(back.encryption.is_none(), "absent encryption costs nothing");
3254        back.validate().expect("decoded capsule validates");
3255    }
3256
3257    #[test]
3258    fn given_a_dead_letter_when_round_tripped_then_payload_should_stay_byte_identical() {
3259        let inner = AgentEnvelope::command(
3260            RecordId::from_u128(9),
3261            ConversationId::from_u128(8),
3262            "source-agent".parse().expect("valid agent id"),
3263            CorrelationId::from_u128(6),
3264            b"poison".to_vec(),
3265        );
3266        let payload = encode_named(&inner).expect("inner encodes");
3267        let capsule = AgentDeadLetter {
3268            source: LogPosition::new(1, 2, 3, 99),
3269            reason: DeadLetterReason::RetryExhausted,
3270            attempts: 5,
3271            detail: Some("handler kept failing".to_owned()),
3272            payload: payload.clone(),
3273        };
3274        let bytes = encode_named(&capsule).expect("encodes");
3275        let back: AgentDeadLetter = decode_named(&bytes).expect("decodes");
3276        assert_eq!(back.payload, payload, "redrive needs the original bytes");
3277        let redrive: AgentEnvelope = decode_named(&back.payload).expect("inner decodes");
3278        assert_eq!(redrive, inner);
3279    }
3280    #[test]
3281    fn given_a_task_status_when_validated_then_should_require_the_correlation() {
3282        // A task status must carry a correlation.
3283        let base = AgentEnvelope::status(
3284            RecordId::from_u128(1),
3285            ConversationId::from_u128(2),
3286            "runner".parse().expect("valid agent id"),
3287            OPERATION_TASK,
3288        )
3289        .with_task_state(TaskState::Working);
3290        assert!(
3291            matches!(
3292                validate(&base),
3293                Err(ValidateError::Missing {
3294                    kind: AgentKind::Status,
3295                    field: "correlation"
3296                })
3297            ),
3298            "a task status without a correlation is rejected"
3299        );
3300        let correlated = base.with_correlation(CorrelationId::from_u128(2));
3301        validate(&correlated)
3302            .expect("the run-lifecycle status validates once it correlates on the run");
3303    }
3304
3305    #[test]
3306    fn given_task_states_when_displayed_and_parsed_then_should_use_the_pinned_kebab_words() {
3307        assert_eq!(TaskState::InputRequired.to_string(), "input-required");
3308        assert_eq!(TaskState::AuthRequired.to_string(), "auth-required");
3309        assert_eq!(TaskState::Unrecognized(42).to_string(), "unrecognized-42");
3310        assert_eq!(
3311            "input-required".parse::<TaskState>().expect("parses"),
3312            TaskState::InputRequired
3313        );
3314        assert!("bogus".parse::<TaskState>().is_err());
3315    }
3316}