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 for a call's duration in microseconds, measured by the
750/// application around the provider or tool call.
751pub const METADATA_DURATION_MICROS: &str = "duration_micros";
752
753/// The inputs and source positions used to assemble one model context.
754#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
755pub struct ContextManifest {
756    pub policy: String,
757    pub policy_version: String,
758    pub fragments: Vec<Fragment>,
759    pub tokens: u64,
760    pub bytes: u64,
761    pub frontier: Vec<(u32, u32, u64)>,
762    #[serde(default, skip_serializing_if = "Option::is_none")]
763    pub correlation: Option<CorrelationId>,
764}
765
766/// One input in the context that a model received.
767#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
768pub enum Fragment {
769    Message {
770        at: crate::graph::SourceRef,
771        tokens: u32,
772        bytes: u32,
773    },
774    Memory {
775        id: String,
776        #[serde(default, skip_serializing_if = "Option::is_none")]
777        version: Option<u64>,
778        #[serde(default, skip_serializing_if = "Option::is_none")]
779        digest: Option<crate::schema::Digest32>,
780        tokens: u32,
781        bytes: u32,
782    },
783    Kv {
784        namespace: String,
785        key: String,
786        #[serde(default, skip_serializing_if = "Option::is_none")]
787        version: Option<u64>,
788        #[serde(default, skip_serializing_if = "Option::is_none")]
789        digest: Option<crate::schema::Digest32>,
790        tokens: u32,
791        bytes: u32,
792    },
793    State {
794        key: String,
795        revision: u64,
796        tokens: u32,
797        bytes: u32,
798    },
799    Summary {
800        at: crate::graph::SourceRef,
801        tokens: u32,
802        bytes: u32,
803    },
804}
805
806/// A summary that replaces a range of context records.
807#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
808pub struct ContextCompaction {
809    pub summary_at: crate::graph::SourceRef,
810    pub covered: Vec<(u32, u32, u64, u64)>,
811    pub summarizer: crate::graph::ProducerInfo,
812}
813
814/// The items selected by one context retrieval.
815#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
816pub struct ContextRetrieval {
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    pub query: Option<String>,
819    pub items: Vec<(String, f32)>,
820}
821
822/// One RFC 6902 operation of a state patch.
823pub use json_patch::PatchOperation;
824
825/// One revision-guarded RFC 6902 state patch.
826#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
827pub struct StateDelta {
828    pub base_revision: u64,
829    pub patch: Vec<json_patch::PatchOperation>,
830    pub op_id: String,
831}
832
833/// A complete state document at the applied revision it replaces.
834#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
835pub struct StateSnapshot {
836    pub base_revision: u64,
837    pub document: serde_json::Value,
838}
839
840impl crate::validate::Validate for ContextManifest {
841    fn validate(&self) -> Result<(), InvalidError> {
842        if self.fragments.len() > crate::limits::MAX_MANIFEST_FRAGMENTS {
843            return Err(InvalidError::new("context manifest has too many fragments"));
844        }
845        for fragment in &self.fragments {
846            let digest = match fragment {
847                Fragment::Memory { digest, .. } | Fragment::Kv { digest, .. } => digest.as_ref(),
848                _ => None,
849            };
850            if let Some(digest) = digest {
851                crate::validate::Validate::validate(digest)?;
852            }
853        }
854        Ok(())
855    }
856}
857
858impl crate::validate::Validate for StateDelta {
859    fn validate(&self) -> Result<(), InvalidError> {
860        if self.patch.len() > crate::limits::MAX_STATE_PATCH_OPS {
861            return Err(InvalidError::new("state patch has too many operations"));
862        }
863        if self.op_id.is_empty() {
864            return Err(InvalidError::new(
865                "state patch operation id must not be empty",
866            ));
867        }
868        let bytes = serde_json::to_vec(&self.patch)
869            .map_err(|error| InvalidError::new(error.to_string()))?;
870        if bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
871            return Err(InvalidError::new(
872                "state patch exceeds the document byte cap",
873            ));
874        }
875        let patch = serde_json::to_value(&self.patch)
876            .map_err(|error| InvalidError::new(error.to_string()))?;
877        validate_portable_json(&patch)?;
878        Ok(())
879    }
880}
881
882impl crate::validate::Validate for StateSnapshot {
883    fn validate(&self) -> Result<(), InvalidError> {
884        let bytes = serde_json::to_vec(&self.document)
885            .map_err(|error| InvalidError::new(error.to_string()))?;
886        if bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
887            return Err(InvalidError::new("state document exceeds its byte cap"));
888        }
889        validate_portable_json(&self.document)?;
890        Ok(())
891    }
892}
893
894const MAX_EXACT_JSON_INTEGER: i128 = 9_007_199_254_740_991;
895
896fn validate_portable_json(value: &serde_json::Value) -> Result<(), InvalidError> {
897    match value {
898        serde_json::Value::Number(number) => {
899            let exact = if let Some(integer) = number.as_i64() {
900                i128::from(integer).abs() <= MAX_EXACT_JSON_INTEGER
901            } else if let Some(integer) = number.as_u64() {
902                i128::from(integer) <= MAX_EXACT_JSON_INTEGER
903            } else {
904                number.as_f64().is_some_and(|float| {
905                    float.is_finite()
906                        && (float.fract() != 0.0 || float.abs() <= MAX_EXACT_JSON_INTEGER as f64)
907                })
908            };
909            if !exact {
910                return Err(InvalidError::new(
911                    "state JSON integer exceeds the exact number range",
912                ));
913            }
914        }
915        serde_json::Value::Array(items) => {
916            for item in items {
917                validate_portable_json(item)?;
918            }
919        }
920        serde_json::Value::Object(fields) => {
921            for item in fields.values() {
922                validate_portable_json(item)?;
923            }
924        }
925        _ => {}
926    }
927    Ok(())
928}
929
930/// Apply a JSON Patch to a copy of a state document and check the shared limits.
931pub fn apply_json_patch(
932    document: &serde_json::Value,
933    patch: &[json_patch::PatchOperation],
934) -> Result<serde_json::Value, InvalidError> {
935    if patch.len() > crate::limits::MAX_STATE_PATCH_OPS {
936        return Err(InvalidError::new("state patch has too many operations"));
937    }
938    let patch_bytes =
939        serde_json::to_vec(patch).map_err(|error| InvalidError::new(error.to_string()))?;
940    if patch_bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
941        return Err(InvalidError::new(
942            "state patch exceeds the document byte cap",
943        ));
944    }
945    validate_portable_json(document)?;
946    let patch_value =
947        serde_json::to_value(patch).map_err(|error| InvalidError::new(error.to_string()))?;
948    validate_portable_json(&patch_value)?;
949    let mut result = document.clone();
950    json_patch::patch(&mut result, patch).map_err(|error| InvalidError::new(error.to_string()))?;
951    crate::validate::Validate::validate(&StateSnapshot {
952        base_revision: 0,
953        document: result.clone(),
954    })?;
955    Ok(result)
956}
957
958impl crate::validate::Validate for SessionStart {
959    fn validate(&self) -> Result<(), InvalidError> {
960        if let Some(label) = &self.label
961            && (label.len() > crate::limits::MAX_SESSION_LABEL_BYTES
962                || label.chars().any(char::is_control))
963        {
964            return Err(InvalidError::new(
965                "session label exceeds its cap or contains a control character",
966            ));
967        }
968        if let Some(namespace) = &self.namespace {
969            crate::kv::validate_namespace(namespace)?;
970        }
971        if self.root.is_some() && self.parent.is_none() {
972            return Err(InvalidError::new("session root requires a parent"));
973        }
974        if self.tags.len() > crate::limits::MAX_MEMORY_TAGS {
975            return Err(InvalidError::new("session has too many tags"));
976        }
977        if self
978            .tags
979            .iter()
980            .any(|tag| tag.len() > crate::limits::MAX_MEMORY_TAG_BYTES)
981        {
982            return Err(InvalidError::new("session tag exceeds its byte cap"));
983        }
984        Ok(())
985    }
986}
987
988/// Typed token accounting, OTel-aligned (`gen_ai.usage.*`, the current
989/// `input_tokens` and `output_tokens` names, never the deprecated
990/// prompt/completion pair).
991///
992/// Advisory analytics input. It is agent-written, so budgets enforce where the
993/// LLM call executes, never on this field.
994#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
995pub struct TokenUsage {
996    pub input_tokens: u64,
997    pub output_tokens: u64,
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub reasoning_output_tokens: Option<u64>,
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub cache_read_input_tokens: Option<u64>,
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub cache_creation_input_tokens: Option<u64>,
1004    /// Cost in integer micro-units of the deployment's configured currency.
1005    #[serde(default, skip_serializing_if = "Option::is_none")]
1006    pub cost_micros: Option<u64>,
1007}
1008
1009/// Why an agent operation failed, as a pinned u8 dictionary (the
1010/// [`TaskState`] pattern: unknown codes decode and pass through).
1011#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1012#[serde(from = "u8", into = "u8")]
1013pub enum AgentErrorCode {
1014    InvalidRequest,
1015    Unauthorized,
1016    Unsupported,
1017    DeadlineExceeded,
1018    Cancelled,
1019    ToolFailure,
1020    Internal,
1021    /// A code this build does not know: pass it through.
1022    Unrecognized(u8),
1023}
1024
1025impl AgentErrorCode {
1026    /// The pinned wire code.
1027    pub const fn code(self) -> u8 {
1028        match self {
1029            AgentErrorCode::InvalidRequest => 1,
1030            AgentErrorCode::Unauthorized => 2,
1031            AgentErrorCode::Unsupported => 3,
1032            AgentErrorCode::DeadlineExceeded => 4,
1033            AgentErrorCode::Cancelled => 5,
1034            AgentErrorCode::ToolFailure => 6,
1035            AgentErrorCode::Internal => 7,
1036            AgentErrorCode::Unrecognized(code) => code,
1037        }
1038    }
1039
1040    /// The error code for a wire code (total).
1041    pub const fn from_code(code: u8) -> Self {
1042        match code {
1043            1 => AgentErrorCode::InvalidRequest,
1044            2 => AgentErrorCode::Unauthorized,
1045            3 => AgentErrorCode::Unsupported,
1046            4 => AgentErrorCode::DeadlineExceeded,
1047            5 => AgentErrorCode::Cancelled,
1048            6 => AgentErrorCode::ToolFailure,
1049            7 => AgentErrorCode::Internal,
1050            other => AgentErrorCode::Unrecognized(other),
1051        }
1052    }
1053}
1054
1055impl From<u8> for AgentErrorCode {
1056    fn from(code: u8) -> Self {
1057        Self::from_code(code)
1058    }
1059}
1060
1061impl From<AgentErrorCode> for u8 {
1062    fn from(code: AgentErrorCode) -> u8 {
1063        code.code()
1064    }
1065}
1066
1067/// The structured body of a `kind = error` envelope, mirroring the wire error
1068/// enums of the other surfaces. The `code` is the machine discriminator, the
1069/// optional `message` is human detail.
1070#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1071pub struct AgentErrorBody {
1072    pub code: AgentErrorCode,
1073    #[serde(default, skip_serializing_if = "Option::is_none")]
1074    pub message: Option<String>,
1075    #[serde(default)]
1076    pub retryable: bool,
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub detail: Option<BTreeMap<String, Value>>,
1079}
1080
1081/// Why a message was dead-lettered, as a pinned u8 dictionary.
1082#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1083#[serde(from = "u8", into = "u8")]
1084pub enum DeadLetterReason {
1085    RetryExhausted,
1086    Rejected,
1087    DecodeFailed,
1088    DeadlineExceeded,
1089    /// A code this build does not know: pass it through.
1090    Unrecognized(u8),
1091}
1092
1093impl DeadLetterReason {
1094    /// The pinned wire code.
1095    pub const fn code(self) -> u8 {
1096        match self {
1097            DeadLetterReason::RetryExhausted => 1,
1098            DeadLetterReason::Rejected => 2,
1099            DeadLetterReason::DecodeFailed => 3,
1100            DeadLetterReason::DeadlineExceeded => 4,
1101            DeadLetterReason::Unrecognized(code) => code,
1102        }
1103    }
1104
1105    /// The reason for a wire code (total).
1106    pub const fn from_code(code: u8) -> Self {
1107        match code {
1108            1 => DeadLetterReason::RetryExhausted,
1109            2 => DeadLetterReason::Rejected,
1110            3 => DeadLetterReason::DecodeFailed,
1111            4 => DeadLetterReason::DeadlineExceeded,
1112            other => DeadLetterReason::Unrecognized(other),
1113        }
1114    }
1115}
1116
1117impl From<u8> for DeadLetterReason {
1118    fn from(code: u8) -> Self {
1119        Self::from_code(code)
1120    }
1121}
1122
1123impl From<DeadLetterReason> for u8 {
1124    fn from(reason: DeadLetterReason) -> u8 {
1125        reason.code()
1126    }
1127}
1128
1129/// The agent-level dead-letter capsule: the poison message's log position, the
1130/// reason, and the original payload verbatim.
1131///
1132/// The payload is the encoded [`AgentEnvelope`], byte-identical, so redrive is
1133/// trivially correct. Republish it to the source topic, or inspect it by
1134/// decoding the inner envelope. The capsule rides a dedicated dead-letter topic
1135/// in the agent stream, CBOR like everything else in AGDX. LaserData Cloud's own
1136/// dead-letter capsules stay operator-facing JSON on a different topic, for a
1137/// different audience.
1138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1139pub struct AgentDeadLetter {
1140    pub source: LogPosition,
1141    pub reason: DeadLetterReason,
1142    pub attempts: u32,
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub detail: Option<String>,
1145    #[serde(with = "crate::encoding::bin_bytes")]
1146    pub payload: Vec<u8>,
1147}
1148
1149/// An advertised skill's health, a pinned u8 dictionary (the [`TaskState`]
1150/// pattern: unknown codes pass through as [`Unrecognized`](Self::Unrecognized)).
1151#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1152#[serde(from = "u8", into = "u8")]
1153pub enum Health {
1154    Healthy,
1155    Degraded,
1156    Unavailable,
1157    /// A code this build does not know.
1158    Unrecognized(u8),
1159}
1160
1161impl Health {
1162    /// The pinned wire code.
1163    pub const fn code(self) -> u8 {
1164        match self {
1165            Health::Healthy => 1,
1166            Health::Degraded => 2,
1167            Health::Unavailable => 3,
1168            Health::Unrecognized(code) => code,
1169        }
1170    }
1171
1172    /// The health for a wire code (unknown codes become
1173    /// [`Unrecognized`](Self::Unrecognized)).
1174    pub const fn from_code(code: u8) -> Self {
1175        match code {
1176            1 => Health::Healthy,
1177            2 => Health::Degraded,
1178            3 => Health::Unavailable,
1179            other => Health::Unrecognized(other),
1180        }
1181    }
1182}
1183
1184impl From<u8> for Health {
1185    fn from(code: u8) -> Self {
1186        Self::from_code(code)
1187    }
1188}
1189
1190impl From<Health> for u8 {
1191    fn from(health: Health) -> u8 {
1192        health.code()
1193    }
1194}
1195
1196/// The content shape on a capability's input or output: either a content-type
1197/// or a registered writer-schema id. Externally tagged (`content_type` /
1198/// `schema_id`) rather than untagged: a content-type name and a schema id are
1199/// both strings, so an untagged encoding would decode a schema id literally named
1200/// `json` as [`ContentType::Json`](crate::content::ContentType::Json), and a
1201/// content-type name from a newer peer as a schema id. The tag makes the two
1202/// unambiguous and forward-safe.
1203#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1204#[serde(rename_all = "snake_case")]
1205pub enum ContentRef {
1206    ContentType(crate::content::ContentType),
1207    SchemaId(String),
1208}
1209
1210/// A structured capability on an [`AgentCard`]: which skill, its I/O content
1211/// shape, advisory cost and latency classes, concurrency, health, and load.
1212/// Mirrors the SDK A2A `AgentSkill` so the bridge maps one to the other.
1213#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1214pub struct CapabilityDescriptor {
1215    /// The skill identifier, capped like every vocabulary string.
1216    pub skill_id: String,
1217    #[serde(default, skip_serializing_if = "Option::is_none")]
1218    pub input: Option<ContentRef>,
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub output: Option<ContentRef>,
1221    /// Advisory cost class (lower is cheaper), opaque to the protocol.
1222    #[serde(default, skip_serializing_if = "Option::is_none")]
1223    pub cost_class: Option<u8>,
1224    /// Advisory latency class (lower is faster), opaque to the protocol.
1225    #[serde(default, skip_serializing_if = "Option::is_none")]
1226    pub latency_class: Option<u8>,
1227    #[serde(default, skip_serializing_if = "Option::is_none")]
1228    pub max_concurrency: Option<u32>,
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub health: Option<Health>,
1231    /// Current load, per-mille of advertised capacity.
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub load: Option<u16>,
1234}
1235
1236/// The pinned minimal body of a liveness or capability card (`status` with
1237/// `operation = card`).
1238///
1239/// Without a pinned shape every bridge and viewer invents its own card variant.
1240/// The publishing agent rides the envelope's `source`, so the card carries only
1241/// what discovery needs. Anything richer is application data in `metadata` or a
1242/// follow-up body, never new card fields by convention.
1243#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1244pub struct AgentCard {
1245    /// Human-readable display label (a viewer shows the base32 id without
1246    /// it). Capped like every vocabulary string.
1247    #[serde(default, skip_serializing_if = "Option::is_none")]
1248    pub name: Option<String>,
1249    /// The agent's own version label, opaque to the protocol.
1250    #[serde(default, skip_serializing_if = "Option::is_none")]
1251    pub version: Option<String>,
1252    /// What the agent serves: structured capability descriptors, capped in both
1253    /// count and per-entry skill-id size.
1254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1255    pub capabilities: Vec<CapabilityDescriptor>,
1256    /// How long this card stays fresh. A card older than its ttl means a dead
1257    /// agent, the convention that makes cards liveness and not just discovery.
1258    #[serde(default, skip_serializing_if = "Option::is_none")]
1259    pub ttl_micros: Option<u64>,
1260}
1261
1262impl AgentCard {
1263    /// Check a decoded card against the caps.
1264    pub fn validate(&self) -> Result<(), ValidateError> {
1265        cap_str(self.name.as_deref(), "name")?;
1266        cap_str(self.version.as_deref(), "version")?;
1267        if self.capabilities.len() > crate::limits::MAX_CARD_CAPABILITIES {
1268            return Err(ValidateError::TooLarge {
1269                field: "capabilities",
1270                size: self.capabilities.len(),
1271                cap: crate::limits::MAX_CARD_CAPABILITIES,
1272            });
1273        }
1274        for capability in &self.capabilities {
1275            cap_str(Some(&capability.skill_id), "capability skill_id")?;
1276        }
1277        Ok(())
1278    }
1279}
1280
1281/// The live presence an agent advertises in its connection metadata
1282/// (`AGDX_SET_CLIENT_METADATA`), the body the discovery read surfaces per
1283/// connection.
1284///
1285/// Distinct from [`AgentCard`] on purpose. The card is durable, replayable
1286/// capability that outlives a brief disconnect (folded from the registry topic).
1287/// Presence is live transport truth: it vanishes on disconnect and
1288/// answers "where do I send this agent work right now," carrying the `inbox`
1289/// topic the agent is currently consuming, which may be a topic it created for
1290/// one workflow and drops when done. Routing resolves a capable agent to its
1291/// inbox through this, never through a hard-coded shared topic name, which does
1292/// not scale across users that each own their own streams and topics.
1293///
1294/// The body rides the opaque connection-metadata bytes with no envelope header,
1295/// so it carries its own version `v` ([`PRESENCE_OP_VERSION`](crate::codes::PRESENCE_OP_VERSION)).
1296/// The metadata channel stays opaque: a non-agent client advertises whatever blob
1297/// its own consumers interpret, and only an AGDX agent advertises this shape.
1298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1299pub struct AgentPresence {
1300    /// The presence body version, carried in-band (the metadata bytes have no
1301    /// out-of-band version header).
1302    pub v: u32,
1303    /// Which agent this connection is, the link from a connection (keyed by
1304    /// `client_id`/`user_id`) to its [`AgentCard`] (keyed by agent id). A claim
1305    /// like every agent-written field, cross-checked against the verified
1306    /// `user_id` the connection authenticated as.
1307    pub agent: AgentId,
1308    /// The topic this agent currently consumes its work on, within the stream its
1309    /// connection is scoped to. Absent means liveness-only presence with no
1310    /// declared inbox, so routing falls through to another signal.
1311    #[serde(default, skip_serializing_if = "Option::is_none")]
1312    pub inbox: Option<String>,
1313}
1314
1315impl AgentPresence {
1316    /// Presence for `agent` at the current [`PRESENCE_OP_VERSION`](crate::codes::PRESENCE_OP_VERSION),
1317    /// with no declared inbox yet.
1318    pub fn new(agent: AgentId) -> Self {
1319        Self {
1320            v: crate::codes::PRESENCE_OP_VERSION,
1321            agent,
1322            inbox: None,
1323        }
1324    }
1325
1326    /// Declare the `inbox` topic this agent consumes its work on.
1327    pub fn with_inbox(mut self, inbox: impl Into<String>) -> Self {
1328        self.inbox = Some(inbox.into());
1329        self
1330    }
1331
1332    /// Check a decoded presence body against the caps: the inbox topic is bounded
1333    /// like every vocabulary string.
1334    pub fn validate(&self) -> Result<(), ValidateError> {
1335        cap_str(self.inbox.as_deref(), "inbox")?;
1336        Ok(())
1337    }
1338}
1339
1340/// The claim-check capsule a `agdx.ct = ref` body carries.
1341///
1342/// The content lives elsewhere (object storage, the KV store, another topic).
1343/// The record carries where it lives, how big it is, and a digest, so any
1344/// consumer verifies the fetched bytes against the log without trusting the
1345/// store. The envelope and the validity matrix are untouched: a referenced
1346/// body is still a `body`, just one whose bytes are a `BodyRef` instead of the
1347/// content itself.
1348#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1349pub struct BodyRef {
1350    /// Where the bytes live: a URI, object key, or KV key. Bounded by
1351    /// [`MAX_BODY_REFERENCE_BYTES`](crate::limits::MAX_BODY_REFERENCE_BYTES).
1352    pub reference: String,
1353    /// The externalized content's size in bytes.
1354    pub size_bytes: u64,
1355    /// SHA-256 of the externalized content, exactly 32 bytes as a CBOR byte string.
1356    #[serde(with = "crate::encoding::bin_bytes")]
1357    pub sha256: Vec<u8>,
1358    /// Encryption scheme code, dormant like [`Signature::scheme`]. Absent means
1359    /// plaintext. Codes are assigned when the key registry (the same registry
1360    /// signatures verify against) lands.
1361    #[serde(default, skip_serializing_if = "Option::is_none")]
1362    pub encryption: Option<u8>,
1363}
1364
1365const SHA256_BYTES: usize = 32;
1366
1367impl BodyRef {
1368    /// A plaintext reference to externalized content.
1369    pub fn new(reference: impl Into<String>, size_bytes: u64, sha256: [u8; 32]) -> Self {
1370        Self {
1371            reference: reference.into(),
1372            size_bytes,
1373            sha256: sha256.to_vec(),
1374            encryption: None,
1375        }
1376    }
1377
1378    /// Check a decoded capsule: non-empty bounded `reference`, 32-byte digest.
1379    ///
1380    /// Receivers run it after decode. [`new`](Self::new) is valid by construction.
1381    pub fn validate(&self) -> Result<(), ValidateError> {
1382        if self.reference.is_empty() {
1383            return Err(ValidateError::Invalid {
1384                field: "reference",
1385                reason: "reference must not be empty".to_owned(),
1386            });
1387        }
1388        if self.reference.len() > crate::limits::MAX_BODY_REFERENCE_BYTES {
1389            return Err(ValidateError::TooLarge {
1390                field: "reference",
1391                size: self.reference.len(),
1392                cap: crate::limits::MAX_BODY_REFERENCE_BYTES,
1393            });
1394        }
1395        if self.sha256.len() != SHA256_BYTES {
1396            return Err(ValidateError::Invalid {
1397                field: "sha256",
1398                reason: format!(
1399                    "digest must be {SHA256_BYTES} bytes, got {}",
1400                    self.sha256.len()
1401                ),
1402            });
1403        }
1404        Ok(())
1405    }
1406}
1407
1408/// A detached envelope signature: designed but dormant.
1409///
1410/// The type exists so the future opt-in (per-agent keys, consumer-side
1411/// verification against a key registry) is an additive
1412/// `signature: Option<Signature>` envelope field, not a redesign. No crypto
1413/// dependency enters this crate. Signing and verification live SDK-side.
1414///
1415/// Field sizes are scheme-discriminated, so the `scheme` byte exists precisely
1416/// to keep the type from welding to one algorithm. The wire fields are bounded
1417/// bytes, [`validate`](Self::validate) enforces the registered per-scheme
1418/// lengths, and unknown schemes pass through.
1419#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1420pub struct Signature {
1421    /// The signing scheme code ([`SIGNATURE_SCHEME_ED25519`] = 1). Codes are
1422    /// permanent, and unknown codes pass through like every open dictionary.
1423    pub scheme: u8,
1424    /// Which of the agent's registered keys signed (8 bytes for Ed25519).
1425    #[serde(with = "crate::encoding::bin_bytes")]
1426    pub key_id: Vec<u8>,
1427    /// The signature over the canonical envelope encoding (signature field
1428    /// absent), domain-separated (64 bytes for Ed25519).
1429    #[serde(with = "crate::encoding::bin_bytes")]
1430    pub bytes: Vec<u8>,
1431    /// Out-of-band interpretation attributes the signature also covers, so an
1432    /// intermediary cannot flip the codec (`agdx.ct`) or wire version (`agdx.av`)
1433    /// on a signed record without invalidating it. Folded into the signed preimage
1434    /// when present. Skip-none, so a context-less signature stays byte-identical to
1435    /// a pre-context one (an older signer, or a body whose interpretation is fixed).
1436    #[serde(default, skip_serializing_if = "Option::is_none")]
1437    pub context: Option<SignatureContext>,
1438}
1439
1440/// The interpretation-bearing attributes a [`Signature`] binds beyond the
1441/// envelope body: the content-type code (`agdx.ct`) and wire version (`agdx.av`)
1442/// a consumer reads to select its codec and decoder. Riding unsigned headers,
1443/// these would let an intermediary reinterpret a signed record. Folded into the
1444/// preimage here, a change invalidates the signature.
1445#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1446pub struct SignatureContext {
1447    /// The `agdx.ct` content-type code the body is encoded with.
1448    #[serde(default, skip_serializing_if = "Option::is_none")]
1449    pub content_type: Option<u8>,
1450    /// The `agdx.av` wire version the envelope is stamped with.
1451    #[serde(default, skip_serializing_if = "Option::is_none")]
1452    pub agent_version: Option<u32>,
1453}
1454
1455/// The Ed25519 signing scheme code.
1456pub const SIGNATURE_SCHEME_ED25519: u8 = 1;
1457
1458/// The domain separator prefixed to the canonical envelope encoding before
1459/// signing, so an AGDX signature can never be replayed into another protocol.
1460/// The canonical encoding is this crate's own: named-field CBOR, fields in
1461/// declaration order, absent optionals skipped, the signature field absent.
1462pub const SIGNATURE_DOMAIN: &[u8] = b"agdx.signature.v1";
1463
1464const ED25519_KEY_ID_BYTES: usize = 8;
1465const ED25519_SIGNATURE_BYTES: usize = 64;
1466
1467impl Signature {
1468    /// Check a signature capsule against its scheme's registered lengths.
1469    ///
1470    /// Unknown scheme codes validate, so future schemes flow through old
1471    /// consumers. Verification itself is SDK-side and scheme-aware.
1472    pub fn validate(&self) -> Result<(), ValidateError> {
1473        if self.scheme != SIGNATURE_SCHEME_ED25519 {
1474            return Ok(());
1475        }
1476        if self.key_id.len() != ED25519_KEY_ID_BYTES {
1477            return Err(ValidateError::Invalid {
1478                field: "key_id",
1479                reason: format!(
1480                    "Ed25519 key id must be {ED25519_KEY_ID_BYTES} bytes, got {}",
1481                    self.key_id.len()
1482                ),
1483            });
1484        }
1485        if self.bytes.len() != ED25519_SIGNATURE_BYTES {
1486            return Err(ValidateError::Invalid {
1487                field: "bytes",
1488                reason: format!(
1489                    "Ed25519 signature must be {ED25519_SIGNATURE_BYTES} bytes, got {}",
1490                    self.bytes.len()
1491                ),
1492            });
1493        }
1494        Ok(())
1495    }
1496}
1497
1498/// The AGDX envelope: one CBOR named-field decode unit per agent message.
1499///
1500/// Field semantics, the per-kind validity matrix, and the caps are enforced by
1501/// [`validate`], and the per-kind constructors stamp the required shape. Routing
1502/// fields (`conversation`, `target`, content type) are also stamped as typed
1503/// headers so projections and plain Iggy consumers work without decoding
1504/// bodies. The envelope is the typed, versioned form of what the headers say.
1505///
1506/// `metadata` is the AGDX-native extension slot, distinct from headers (the
1507/// substrate and observability dictionary) and `body` (the content). Foreign
1508/// metadata (A2A `metadata`, MCP `_meta`) never maps into it. It tunnels whole
1509/// inside `body`, keeping bridge round trips byte-identical.
1510#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1511pub struct AgentEnvelope {
1512    pub kind: AgentKind,
1513    /// Producer-assigned record identity, required on every kind except
1514    /// `chunk` (chunks are identified by `channel` + `sequence`, saving the
1515    /// id bytes and a clock-and-entropy call per token).
1516    #[serde(default, skip_serializing_if = "Option::is_none")]
1517    pub record: Option<RecordId>,
1518    /// Also the partition key.
1519    pub conversation: ConversationId,
1520    /// The session this conversation was spawned from.
1521    #[serde(default, skip_serializing_if = "Option::is_none")]
1522    pub parent: Option<ConversationId>,
1523    /// The first conversation in this session tree.
1524    #[serde(default, skip_serializing_if = "Option::is_none")]
1525    pub root: Option<ConversationId>,
1526    /// Agent-authorship claim (see the module docs).
1527    pub source: AgentId,
1528    /// Routing refinement within a shared topic. The topic itself is the
1529    /// primary address. Consumer-side filtering by `target` is a convenience,
1530    /// not a confidentiality control. The topic is the boundary.
1531    #[serde(default, skip_serializing_if = "Option::is_none")]
1532    pub target: Option<AgentId>,
1533    /// The causal parent's record id: the identity half of the causal pointer,
1534    /// stable across replication.
1535    #[serde(default, skip_serializing_if = "Option::is_none")]
1536    pub cause: Option<RecordId>,
1537    /// The causal parent's log position: the locator half, an O(1) dereference
1538    /// for raw log walkers, deployment-local.
1539    #[serde(default, skip_serializing_if = "Option::is_none")]
1540    pub cause_at: Option<LogPosition>,
1541    /// Request/reply pairing. A2A task identity maps onto it.
1542    #[serde(default, skip_serializing_if = "Option::is_none")]
1543    pub correlation: Option<CorrelationId>,
1544    /// Chunk grouping when many streams run under one correlation.
1545    #[serde(default, skip_serializing_if = "Option::is_none")]
1546    pub channel: Option<ChannelId>,
1547    #[serde(default, skip_serializing_if = "Option::is_none")]
1548    pub idempotency_key: Option<IdempotencyKey>,
1549    /// Drop-dead time, epoch micros. On a stream-opening message it is also
1550    /// the reader-local abandonment bound: the producer knows its own model
1551    /// timeout, the consumer would be guessing.
1552    #[serde(default, skip_serializing_if = "Option::is_none")]
1553    pub deadline_micros: Option<u64>,
1554    /// Chunk ordering within a channel.
1555    #[serde(default, skip_serializing_if = "Option::is_none")]
1556    pub sequence: Option<u64>,
1557    /// Terminal flag: the final chunk of a stream / the final task update.
1558    /// `false` is equivalent to absence and is skipped on encode, so only `true`
1559    /// has protocol meaning. Maps one-to-one onto A2A's `final`.
1560    #[serde(default, skip_serializing_if = "is_false")]
1561    pub last: bool,
1562    /// Why a stream or response ended (OTel finish-reason vocabulary: stop,
1563    /// length, content_filter, tool_call, ...). A string deliberately: that
1564    /// vocabulary belongs to OTel and the providers, not to us.
1565    #[serde(default, skip_serializing_if = "Option::is_none")]
1566    pub finish_reason: Option<String>,
1567    #[serde(default, skip_serializing_if = "Option::is_none")]
1568    pub task_state: Option<TaskState>,
1569    /// OTel `gen_ai.operation.name` value (`chat`, `execute_tool`, ...). On
1570    /// `status` it is the required discriminator (`task`, `card`, `progress`).
1571    #[serde(default, skip_serializing_if = "Option::is_none")]
1572    pub operation: Option<String>,
1573    /// OTel `gen_ai.tool.name`, for tool commands and results.
1574    #[serde(default, skip_serializing_if = "Option::is_none")]
1575    pub tool: Option<String>,
1576    #[serde(default, skip_serializing_if = "Option::is_none")]
1577    pub usage: Option<TokenUsage>,
1578    /// AGDX-native scalar extension context. Never foreign metadata.
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub metadata: Option<BTreeMap<String, Value>>,
1581    /// Must-understand marker: a bitset of feature bits a receiver MUST
1582    /// understand to process this message correctly (see [`features`]). A
1583    /// receiver that sees a set bit it does not implement MUST reject or
1584    /// dead-letter the message rather than mis-handle it ([`unmet_requirements`]).
1585    /// `0` (the default, skipped on the wire so pre-marker records stay
1586    /// byte-identical) means "ignore anything you don't understand", the
1587    /// open-world default. This lets one message demand strict handling of a new
1588    /// feature without a whole-envelope version bump (a `must_understand` marker).
1589    ///
1590    /// [`features`]: crate::agent::features
1591    /// [`unmet_requirements`]: AgentEnvelope::unmet_requirements
1592    #[serde(default, skip_serializing_if = "is_zero_u64")]
1593    pub must_understand: u64,
1594    /// The content, codec per the `agdx.ct` header. Default-empty and skipped
1595    /// when empty. The validity matrix says which kinds require it.
1596    #[serde(
1597        default,
1598        skip_serializing_if = "Vec::is_empty",
1599        with = "crate::encoding::bin_bytes"
1600    )]
1601    pub body: Vec<u8>,
1602    /// A detached signature over the canonical encoding of this envelope with the
1603    /// signature field absent, domain-separated by [`SIGNATURE_DOMAIN`]. Opt-in:
1604    /// absent (the open-world default, skipped on the wire so an unsigned record
1605    /// stays byte-identical) means an unsigned record. Verification is SDK-side
1606    /// against a per-agent key registry, so the wire crate stays crypto-free. A
1607    /// signature may ride any kind (no matrix row, like `metadata`).
1608    #[serde(default, skip_serializing_if = "Option::is_none")]
1609    pub signature: Option<Signature>,
1610}
1611
1612fn is_false(value: &bool) -> bool {
1613    !*value
1614}
1615
1616fn is_zero_u64(value: &u64) -> bool {
1617    *value == 0
1618}
1619
1620/// Must-understand feature bits for [`AgentEnvelope::must_understand`]. Each
1621/// constant names one capability a message may demand a receiver implement.
1622/// Defined additively as features land: a newer producer sets a bit an older
1623/// receiver does not know, and that receiver rejects rather than mis-handling
1624/// the message. No bits are defined yet, so today the marker is the mechanism
1625/// in place for the first feature that needs strict handling.
1626pub mod features {
1627    /// A receiver's full understood set is the OR of the bits it implements.
1628    /// With no feature bits defined yet, a current build understands none and
1629    /// only ever sees `must_understand == 0`.
1630    pub const NONE: u64 = 0;
1631}
1632
1633impl AgentEnvelope {
1634    fn base(kind: AgentKind, conversation: ConversationId, source: AgentId) -> Self {
1635        Self {
1636            kind,
1637            record: None,
1638            conversation,
1639            parent: None,
1640            root: None,
1641            source,
1642            target: None,
1643            cause: None,
1644            cause_at: None,
1645            correlation: None,
1646            channel: None,
1647            idempotency_key: None,
1648            deadline_micros: None,
1649            sequence: None,
1650            last: false,
1651            finish_reason: None,
1652            task_state: None,
1653            operation: None,
1654            tool: None,
1655            usage: None,
1656            metadata: None,
1657            must_understand: 0,
1658            body: Vec::new(),
1659            signature: None,
1660        }
1661    }
1662
1663    /// Declare that a receiver MUST understand the feature `bits` to process this
1664    /// message. Bits a receiver lacks make it reject the message
1665    /// ([`unmet_requirements`](Self::unmet_requirements)). Absent bits are
1666    /// ignore-if-unknown. Additive builder method.
1667    #[must_use]
1668    pub fn requiring(mut self, bits: u64) -> Self {
1669        self.must_understand = bits;
1670        self
1671    }
1672
1673    /// The subset of this message's [`must_understand`](Self::must_understand)
1674    /// bits NOT present in the receiver's `understood` set. Non-zero means the
1675    /// receiver cannot safely process the message and must reject or
1676    /// dead-letter it rather than mis-handle a feature it does not implement.
1677    pub fn unmet_requirements(&self, understood: u64) -> u64 {
1678        self.must_understand & !understood
1679    }
1680
1681    /// A `command`: expects a reply or effect, so `correlation` is required.
1682    pub fn command(
1683        record: RecordId,
1684        conversation: ConversationId,
1685        source: AgentId,
1686        correlation: CorrelationId,
1687        body: Vec<u8>,
1688    ) -> Self {
1689        let mut envelope = Self::base(AgentKind::Command, conversation, source);
1690        envelope.record = Some(record);
1691        envelope.correlation = Some(correlation);
1692        envelope.body = body;
1693        envelope
1694    }
1695
1696    /// A `response`: the paired answer to a command (same `correlation`).
1697    pub fn response(
1698        record: RecordId,
1699        conversation: ConversationId,
1700        source: AgentId,
1701        correlation: CorrelationId,
1702        body: Vec<u8>,
1703    ) -> Self {
1704        let mut envelope = Self::base(AgentKind::Response, conversation, source);
1705        envelope.record = Some(record);
1706        envelope.correlation = Some(correlation);
1707        envelope.body = body;
1708        envelope
1709    }
1710
1711    /// An `event`: expects nothing.
1712    pub fn event(
1713        record: RecordId,
1714        conversation: ConversationId,
1715        source: AgentId,
1716        body: Vec<u8>,
1717    ) -> Self {
1718        let mut envelope = Self::base(AgentKind::Event, conversation, source);
1719        envelope.record = Some(record);
1720        envelope.body = body;
1721        envelope
1722    }
1723
1724    /// A `chunk` of the stream `channel`, ordered by `sequence`. Mark the
1725    /// final one with [`terminal`](Self::terminal).
1726    pub fn chunk(
1727        conversation: ConversationId,
1728        source: AgentId,
1729        correlation: CorrelationId,
1730        channel: ChannelId,
1731        sequence: u64,
1732        body: Vec<u8>,
1733    ) -> Self {
1734        let mut envelope = Self::base(AgentKind::Chunk, conversation, source);
1735        envelope.correlation = Some(correlation);
1736        envelope.channel = Some(channel);
1737        envelope.sequence = Some(sequence);
1738        envelope.body = body;
1739        envelope
1740    }
1741
1742    /// A `status` signal discriminated by `operation` (`task`, `card`,
1743    /// `progress`). Task updates additionally require `correlation` and
1744    /// `task_state` (use the with-setters).
1745    pub fn status(
1746        record: RecordId,
1747        conversation: ConversationId,
1748        source: AgentId,
1749        operation: impl Into<String>,
1750    ) -> Self {
1751        let mut envelope = Self::base(AgentKind::Status, conversation, source);
1752        envelope.record = Some(record);
1753        envelope.operation = Some(operation.into());
1754        envelope
1755    }
1756
1757    /// An `error` terminal for `correlation`. `body` is the encoded
1758    /// [`AgentErrorBody`].
1759    pub fn error(
1760        record: RecordId,
1761        conversation: ConversationId,
1762        source: AgentId,
1763        correlation: CorrelationId,
1764        body: Vec<u8>,
1765    ) -> Self {
1766        let mut envelope = Self::base(AgentKind::Error, conversation, source);
1767        envelope.record = Some(record);
1768        envelope.correlation = Some(correlation);
1769        envelope.body = body;
1770        envelope
1771    }
1772
1773    /// Narrow delivery to one agent within a shared topic.
1774    pub fn with_target(mut self, target: AgentId) -> Self {
1775        self.target = Some(target);
1776        self
1777    }
1778
1779    /// Stamp the causal parent: its record id (identity) and, when known, its
1780    /// log position (locator). A handler has both for free from the message it
1781    /// is replying to.
1782    pub fn with_cause(mut self, cause: RecordId, cause_at: Option<LogPosition>) -> Self {
1783        self.cause = Some(cause);
1784        self.cause_at = cause_at;
1785        self
1786    }
1787
1788    /// Pair this message with a correlation id. Required on `command`,
1789    /// `response`, `error`, and `chunk`. Optional on `event` and non-task
1790    /// `status`.
1791    pub fn with_correlation(mut self, correlation: CorrelationId) -> Self {
1792        self.correlation = Some(correlation);
1793        self
1794    }
1795
1796    /// Attach a business idempotency key (commands, responses, events only).
1797    pub fn with_idempotency_key(mut self, key: IdempotencyKey) -> Self {
1798        self.idempotency_key = Some(key);
1799        self
1800    }
1801
1802    /// Declare the drop-dead time (and, on a stream-opening message, the
1803    /// abandonment bound).
1804    pub fn with_deadline_micros(mut self, deadline_micros: u64) -> Self {
1805        self.deadline_micros = Some(deadline_micros);
1806        self
1807    }
1808
1809    /// Mark this message terminal (`last = true`), with the reason the stream
1810    /// or response ended.
1811    pub fn terminal(mut self, finish_reason: impl Into<String>) -> Self {
1812        self.last = true;
1813        self.finish_reason = Some(finish_reason.into());
1814        self
1815    }
1816
1817    /// Attach a task state. Status task updates require it. Responses and
1818    /// errors may carry it as the one-message terminal convenience.
1819    pub fn with_task_state(mut self, state: TaskState) -> Self {
1820        self.task_state = Some(state);
1821        self
1822    }
1823
1824    /// Set the OTel operation name.
1825    pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
1826        self.operation = Some(operation.into());
1827        self
1828    }
1829
1830    /// Set the OTel tool name.
1831    pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
1832        self.tool = Some(tool.into());
1833        self
1834    }
1835
1836    /// Attach token accounting (advisory).
1837    pub fn with_usage(mut self, usage: TokenUsage) -> Self {
1838        self.usage = Some(usage);
1839        self
1840    }
1841
1842    /// Add one AGDX-native metadata entry.
1843    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1844        self.metadata
1845            .get_or_insert_with(BTreeMap::new)
1846            .insert(key.into(), value.into());
1847        self
1848    }
1849
1850    /// Attach a detached [`Signature`] over this envelope. The SDK signs the
1851    /// canonical encoding with the signature absent and domain separator
1852    /// [`SIGNATURE_DOMAIN`], so the field is set last. A signature may ride any
1853    /// kind.
1854    pub fn with_signature(mut self, signature: Signature) -> Self {
1855        self.signature = Some(signature);
1856        self
1857    }
1858}
1859
1860/// A validity-matrix or cap violation. Receivers treat these as protocol
1861/// errors rather than guessing, and the SDK rejects them at publish time.
1862#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1863#[non_exhaustive]
1864pub enum ValidateError {
1865    #[error("{kind} requires `{field}`")]
1866    Missing {
1867        kind: AgentKind,
1868        field: &'static str,
1869    },
1870    #[error("`{field}` is invalid on {kind}")]
1871    Forbidden {
1872        kind: AgentKind,
1873        field: &'static str,
1874    },
1875    #[error("`{field}` is {size}B, exceeds cap {cap}B")]
1876    TooLarge {
1877        field: &'static str,
1878        size: usize,
1879        cap: usize,
1880    },
1881    #[error("`{field}`: {reason}")]
1882    Invalid { field: &'static str, reason: String },
1883}
1884
1885// The per-kind validity matrix, mechanical. R = required, O = optional,
1886// X = invalid:
1887//
1888// | field            | command | response | event | chunk | status | error |
1889// |------------------|---------|----------|-------|-------|--------|-------|
1890// | record           | R       | R        | R     | O     | R      | R     |
1891// | conversation     | R       | R        | R     | R     | R      | R     |
1892// | source           | R       | R        | R     | R     | R      | R     |
1893// | target           | O       | O        | O     | O     | O      | O     |
1894// | cause / cause_at | O       | O        | O     | O     | O      | O     |
1895// | correlation      | R       | R        | O     | R     | O (R task) | R |
1896// | channel          | X       | X        | X     | R     | X      | O     |
1897// | sequence         | X       | X        | X     | R     | X      | O (with channel) |
1898// | last             | X       | X        | X     | O     | O      | X (always terminal) |
1899// | finish_reason    | X       | O        | X     | O (with last) | X | X  |
1900// | idempotency_key  | O       | O        | O     | X     | X      | X     |
1901// | deadline_micros  | O       | X        | X     | O     | X      | X     |
1902// | task_state       | X       | O        | X     | X     | R (task|session) | O |
1903// | operation        | O       | O        | O     | R seq 0 (chat|reasoning|tool_args), X after | R (task|session|card|progress|quarantine|unquarantine) | O |
1904// | tool             | O       | O        | O     | O     | X      | O     |
1905// | usage            | X       | O        | O     | O (terminal) | O | O  |
1906// | metadata         | O       | O        | O     | O     | O      | O     |
1907// | body             | R       | R        | R     | R (may be empty with last) | R session, O otherwise | R |
1908/// Check an envelope against the per-kind validity matrix and the caps.
1909pub fn validate(envelope: &AgentEnvelope) -> Result<(), ValidateError> {
1910    use AgentKind::*;
1911    let kind = envelope.kind;
1912
1913    let require = |present: bool, field: &'static str| {
1914        if present {
1915            Ok(())
1916        } else {
1917            Err(ValidateError::Missing { kind, field })
1918        }
1919    };
1920    let forbid = |absent: bool, field: &'static str| {
1921        if absent {
1922            Ok(())
1923        } else {
1924            Err(ValidateError::Forbidden { kind, field })
1925        }
1926    };
1927
1928    // record: required everywhere except chunk (where it is optional).
1929    if kind != Chunk {
1930        require(envelope.record.is_some(), "record")?;
1931    }
1932
1933    if envelope.root.is_some() && envelope.parent.is_none() {
1934        return Err(ValidateError::Invalid {
1935            field: "root",
1936            reason: "root requires parent".to_owned(),
1937        });
1938    }
1939    if envelope.parent == Some(envelope.conversation)
1940        || envelope.root == Some(envelope.conversation)
1941    {
1942        return Err(ValidateError::Invalid {
1943            field: "parent",
1944            reason: "parent and root must differ from conversation".to_owned(),
1945        });
1946    }
1947
1948    // correlation.
1949    match kind {
1950        Command | Response | Chunk | Error => {
1951            require(envelope.correlation.is_some(), "correlation")?
1952        }
1953        Status => {
1954            if envelope.operation.as_deref() == Some(OPERATION_TASK) {
1955                require(envelope.correlation.is_some(), "correlation")?;
1956            }
1957        }
1958        Event => {}
1959    }
1960
1961    // channel + sequence: the chunk identity, allowed on error as a stream
1962    // terminal, sequence only alongside channel.
1963    match kind {
1964        Chunk => {
1965            require(envelope.channel.is_some(), "channel")?;
1966            require(envelope.sequence.is_some(), "sequence")?;
1967        }
1968        Error => {
1969            if envelope.sequence.is_some() && envelope.channel.is_none() {
1970                return Err(ValidateError::Invalid {
1971                    field: "sequence",
1972                    reason: "sequence requires channel".to_owned(),
1973                });
1974            }
1975        }
1976        _ => {
1977            forbid(envelope.channel.is_none(), "channel")?;
1978            forbid(envelope.sequence.is_none(), "sequence")?;
1979        }
1980    }
1981
1982    // last: chunk and status only (error is always terminal, so the flag
1983    // would be redundant noise there).
1984    if envelope.last && !matches!(kind, Chunk | Status) {
1985        return Err(ValidateError::Forbidden {
1986            kind,
1987            field: "last",
1988        });
1989    }
1990
1991    // finish_reason: responses, and terminal chunks.
1992    match kind {
1993        Response => {}
1994        Chunk => {
1995            if envelope.finish_reason.is_some() && !envelope.last {
1996                return Err(ValidateError::Invalid {
1997                    field: "finish_reason",
1998                    reason: "finish_reason rides only the terminal chunk".to_owned(),
1999                });
2000            }
2001        }
2002        _ => forbid(envelope.finish_reason.is_none(), "finish_reason")?,
2003    }
2004
2005    // idempotency_key: business idempotency for command/response/event.
2006    // Chunks and signals carry no dedup semantics.
2007    if matches!(kind, Chunk | Status | Error) {
2008        forbid(envelope.idempotency_key.is_none(), "idempotency_key")?;
2009    }
2010
2011    // deadline_micros: commands and stream-opening chunks (the abandonment
2012    // bound rides sequence 0, and a mid-stream deadline would be ambiguous).
2013    if matches!(kind, Response | Event | Status | Error) {
2014        forbid(envelope.deadline_micros.is_none(), "deadline_micros")?;
2015    }
2016    if kind == Chunk && envelope.deadline_micros.is_some() && envelope.sequence != Some(0) {
2017        return Err(ValidateError::Invalid {
2018            field: "deadline_micros",
2019            reason: "the stream bound rides the opening chunk (sequence 0)".to_owned(),
2020        });
2021    }
2022
2023    // task_state.
2024    match kind {
2025        Status => {
2026            if matches!(
2027                envelope.operation.as_deref(),
2028                Some(OPERATION_TASK | OPERATION_SESSION)
2029            ) {
2030                require(envelope.task_state.is_some(), "task_state")?;
2031            }
2032        }
2033        Response | Error => {}
2034        _ => forbid(envelope.task_state.is_none(), "task_state")?,
2035    }
2036
2037    // operation: two CLOSED vocabularies (protocol machinery, version-gated
2038    // like AgentKind), open OTel values everywhere else. The status
2039    // discriminator must be task | session | card | progress, and the chunk-stream purpose
2040    // must be chat | reasoning | tool_args and rides ONLY the opening chunk
2041    // (sequence 0), where it is required.
2042    match kind {
2043        Status => {
2044            require(envelope.operation.is_some(), "operation")?;
2045            if let Some(operation) = envelope.operation.as_deref()
2046                && !matches!(
2047                    operation,
2048                    OPERATION_TASK
2049                        | OPERATION_SESSION
2050                        | OPERATION_CARD
2051                        | OPERATION_PROGRESS
2052                        | OPERATION_QUARANTINE
2053                        | OPERATION_UNQUARANTINE
2054                )
2055            {
2056                return Err(ValidateError::Invalid {
2057                    field: "operation",
2058                    reason: format!(
2059                        "status operation must be `{OPERATION_TASK}`, `{OPERATION_SESSION}`, `{OPERATION_CARD}`, \
2060                         `{OPERATION_PROGRESS}`, `{OPERATION_QUARANTINE}`, or \
2061                         `{OPERATION_UNQUARANTINE}`, got `{operation}`"
2062                    ),
2063                });
2064            }
2065        }
2066        Chunk => {
2067            if envelope.sequence == Some(0) {
2068                require(envelope.operation.is_some(), "operation")?;
2069            }
2070            if let Some(operation) = envelope.operation.as_deref() {
2071                if envelope.sequence != Some(0) {
2072                    return Err(ValidateError::Invalid {
2073                        field: "operation",
2074                        reason: "the stream purpose rides the opening chunk (sequence 0)"
2075                            .to_owned(),
2076                    });
2077                }
2078                if !matches!(
2079                    operation,
2080                    OPERATION_CHAT | OPERATION_REASONING | OPERATION_TOOL_ARGS
2081                ) {
2082                    return Err(ValidateError::Invalid {
2083                        field: "operation",
2084                        reason: format!(
2085                            "chunk-stream purpose must be `{OPERATION_CHAT}`, \
2086                             `{OPERATION_REASONING}`, or `{OPERATION_TOOL_ARGS}`, \
2087                             got `{operation}`"
2088                        ),
2089                    });
2090                }
2091            }
2092        }
2093        Command | Response | Event | Error => {}
2094    }
2095
2096    // tool: meaningless on status signals.
2097    if kind == Status {
2098        forbid(envelope.tool.is_none(), "tool")?;
2099    }
2100
2101    // usage: terminal-chunk accounting, never on commands.
2102    match kind {
2103        Command => forbid(envelope.usage.is_none(), "usage")?,
2104        Chunk if envelope.usage.is_some() && !envelope.last => {
2105            return Err(ValidateError::Invalid {
2106                field: "usage",
2107                reason: "whole-stream accounting rides the terminal chunk".to_owned(),
2108            });
2109        }
2110        _ => {}
2111    }
2112
2113    // body.
2114    match kind {
2115        Status if envelope.operation.as_deref() == Some(OPERATION_SESSION) => {
2116            require(!envelope.body.is_empty(), "body")?;
2117            let state = envelope
2118                .task_state
2119                .expect("session state was required above");
2120            if envelope.last != state.is_terminal() {
2121                return Err(ValidateError::Invalid {
2122                    field: "last",
2123                    reason: "session last must match terminal task state".to_owned(),
2124                });
2125            }
2126            // The body shape is checked where the CBOR codec is built in.
2127            #[cfg(feature = "cbor")]
2128            {
2129                let invalid_body = |reason: String| ValidateError::Invalid {
2130                    field: "body",
2131                    reason,
2132                };
2133                let validate_start = |start: &SessionStart| {
2134                    crate::validate::Validate::validate(start)
2135                        .map_err(|error| invalid_body(error.to_string()))?;
2136                    if start.parent != envelope.parent || start.root != envelope.root {
2137                        return Err(invalid_body(
2138                            "session ancestry must match envelope parent and root".to_owned(),
2139                        ));
2140                    }
2141                    Ok(())
2142                };
2143                match state {
2144                    TaskState::Submitted => {
2145                        let start: SessionStart = crate::framing::decode_named(&envelope.body)
2146                            .map_err(|error| invalid_body(error.to_string()))?;
2147                        validate_start(&start)?;
2148                    }
2149                    TaskState::Working => {
2150                        let body: ciborium::value::Value =
2151                            crate::framing::decode_named(&envelope.body)
2152                                .map_err(|error| invalid_body(error.to_string()))?;
2153                        let is_start = matches!(
2154                            body,
2155                            ciborium::value::Value::Map(ref entries)
2156                                if entries.iter().any(|(key, _)| {
2157                                    matches!(key, ciborium::value::Value::Text(name) if name == "agent" || name == "sdk")
2158                                })
2159                        );
2160                        if is_start {
2161                            let start: SessionStart = crate::framing::decode_named(&envelope.body)
2162                                .map_err(|error| invalid_body(error.to_string()))?;
2163                            validate_start(&start)?;
2164                        } else {
2165                            crate::framing::decode_named::<SessionTransition>(&envelope.body)
2166                                .map_err(|error| invalid_body(error.to_string()))?;
2167                        }
2168                    }
2169                    state if state.is_terminal() => {
2170                        crate::framing::decode_named::<SessionEnd>(&envelope.body)
2171                            .map_err(|error| invalid_body(error.to_string()))?;
2172                    }
2173                    _ => {
2174                        crate::framing::decode_named::<SessionTransition>(&envelope.body)
2175                            .map_err(|error| invalid_body(error.to_string()))?;
2176                    }
2177                }
2178            }
2179        }
2180        Status => {}
2181        Chunk => {
2182            if envelope.body.is_empty() && !envelope.last {
2183                return Err(ValidateError::Missing {
2184                    kind,
2185                    field: "body",
2186                });
2187            }
2188        }
2189        _ => require(!envelope.body.is_empty(), "body")?,
2190    }
2191
2192    // Caps. The metadata caps are the load-bearing ones: that field is
2193    // bridge-injected and foreign-influenced, so a hostile edge gets a
2194    // publish-time rejection instead of inflating every record on a topic.
2195    cap_str(envelope.operation.as_deref(), "operation")?;
2196    cap_str(envelope.tool.as_deref(), "tool")?;
2197    cap_str(envelope.finish_reason.as_deref(), "finish_reason")?;
2198    if let Some(metadata) = &envelope.metadata {
2199        if metadata.len() > crate::limits::MAX_METADATA_ENTRIES {
2200            return Err(ValidateError::TooLarge {
2201                field: "metadata",
2202                size: metadata.len(),
2203                cap: crate::limits::MAX_METADATA_ENTRIES,
2204            });
2205        }
2206        let mut total = 0usize;
2207        for (key, value) in metadata {
2208            if key.len() > crate::limits::MAX_METADATA_KEY_BYTES {
2209                return Err(ValidateError::TooLarge {
2210                    field: "metadata key",
2211                    size: key.len(),
2212                    cap: crate::limits::MAX_METADATA_KEY_BYTES,
2213                });
2214            }
2215            let value_size = value_size(value);
2216            if value_size > crate::limits::MAX_METADATA_VALUE_BYTES {
2217                return Err(ValidateError::TooLarge {
2218                    field: "metadata value",
2219                    size: value_size,
2220                    cap: crate::limits::MAX_METADATA_VALUE_BYTES,
2221                });
2222            }
2223            total += key.len() + value_size;
2224        }
2225        if total > crate::limits::MAX_METADATA_TOTAL_BYTES {
2226            return Err(ValidateError::TooLarge {
2227                field: "metadata",
2228                size: total,
2229                cap: crate::limits::MAX_METADATA_TOTAL_BYTES,
2230            });
2231        }
2232    }
2233    // A signature may ride any kind (no matrix row, like metadata). When present
2234    // its capsule must hold its scheme's registered lengths.
2235    if let Some(signature) = &envelope.signature {
2236        signature.validate()?;
2237    }
2238    Ok(())
2239}
2240
2241/// The `status` operation value for task lifecycle updates.
2242pub const OPERATION_TASK: &str = "task";
2243/// The `status` operation value for session lifecycle updates.
2244pub const OPERATION_SESSION: &str = "session";
2245/// The `status` operation value for liveness/capability cards.
2246pub const OPERATION_CARD: &str = "card";
2247/// The `status` operation value for progress ticks.
2248pub const OPERATION_PROGRESS: &str = "progress";
2249/// The `status` operation value for a quarantine fact: an operator marks an agent
2250/// out of routing. The body is the quarantined agent id. Authorized by the
2251/// registry topic's write access control (only an operator may append it), and
2252/// optionally signed for defense in depth.
2253pub const OPERATION_QUARANTINE: &str = "quarantine";
2254/// The `status` operation value for an un-quarantine fact: an operator lifts a
2255/// prior quarantine, returning the agent to routing. The body is the agent id.
2256/// Same authorization as [`OPERATION_QUARANTINE`], so quarantine is not a
2257/// one-way door that only retention expiry can undo.
2258pub const OPERATION_UNQUARANTINE: &str = "unquarantine";
2259
2260// The chunk-stream purpose vocabulary: `operation` on the stream-opening
2261// chunk says what the channel IS, so a consumer reassembling several channels
2262// under one correlation (answer text next to reasoning next to streamed tool
2263// arguments) tells them apart without decoding bodies. Pinned here because
2264// every bridge and viewer would otherwise invent its own spelling.
2265/// The chunk-stream `operation` value for answer/content text (OTel's `chat`).
2266pub const OPERATION_CHAT: &str = "chat";
2267/// The chunk-stream `operation` value for a model's reasoning stream.
2268pub const OPERATION_REASONING: &str = "reasoning";
2269/// The chunk-stream `operation` value for streamed tool-call arguments.
2270pub const OPERATION_TOOL_ARGS: &str = "tool_args";
2271
2272// The state-sync convention: UI/shared state rides `event` envelopes
2273// discriminated by `operation`, never a new kind. Replaying snapshot + deltas
2274// reconstructs the state at any historical offset.
2275/// The `event` operation value for a full state snapshot (the body is the
2276/// state, codec per `agdx.ct`).
2277pub const OPERATION_STATE_SNAPSHOT: &str = "state_snapshot";
2278/// The `event` operation value for a state delta (the body is an RFC 6902
2279/// JSON Patch document).
2280pub const OPERATION_STATE_DELTA: &str = "state_delta";
2281/// The `event` operation value for a work record an agent held while its
2282/// session was paused. The body is a [`SessionParking`].
2283pub const OPERATION_SESSION_PARKED: &str = "session_parked";
2284/// The `event` operation value that records a held work record handled after
2285/// the resume. The body is the [`SessionParking`] of the held record.
2286pub const OPERATION_SESSION_UNPARKED: &str = "session_unparked";
2287
2288// Pinned AGDX-native metadata keys. Values stay strings or scalars per the
2289// metadata rules. The keys are pinned so transcripts, bridges, and projections
2290// agree without per-app conventions.
2291/// Metadata key: the message's chat role. Recommended values: `user`,
2292/// `assistant`, `system`, `tool`. A string because that vocabulary belongs to
2293/// the model providers and the edge protocols, not to us.
2294pub const METADATA_ROLE: &str = "role";
2295/// Metadata key: the bridge hop list, a `Value::List` of bridge id strings.
2296/// A bridge republishing a message appends its own id, and drops a message
2297/// whose hop list already contains it: the loop guard for multi-bridge
2298/// deployments (A2A in, AG-UI out, A2A out again). Bounded by the metadata
2299/// caps like every other entry.
2300pub const METADATA_BRIDGE_HOPS: &str = "bridge_hops";
2301/// Metadata key: the user an agent acts on behalf of. It rides `metadata` (not a
2302/// header) so it falls inside the signed envelope span, so the signer cannot
2303/// forge whom it claims to act for.
2304pub const METADATA_DELEGATED_BY: &str = "on_behalf_of";
2305/// Metadata key: the declared purpose of the operation, a stable input for a
2306/// policy engine at the effect boundary. Advisory unless the envelope is signed.
2307pub const METADATA_PURPOSE: &str = "purpose";
2308/// Metadata key: the declared classification of the data the operation touches.
2309/// Advisory unless the envelope is signed.
2310pub const METADATA_DATA_CLASSIFICATION: &str = "data_classification";
2311/// Metadata key: the task this operation serves. Advisory unless the envelope
2312/// is signed.
2313pub const METADATA_TASK_CONTEXT: &str = "task_context";
2314/// Metadata key: the session's declared intent. Advisory unless the envelope
2315/// is signed.
2316pub const METADATA_SESSION_INTENT: &str = "session_intent";
2317
2318// Crockford base32, the canonical display form of every u128 id (26
2319// characters, the same rendering ULIDs use). Hand-rolled so the crate stays
2320// dependency-free. Generation (entropy and clock) lives SDK-side.
2321const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2322
2323pub(crate) fn crockford_encode(value: u128) -> [u8; 26] {
2324    let mut out = [0u8; 26];
2325    let mut v = value;
2326    for slot in out.iter_mut().rev() {
2327        *slot = CROCKFORD[(v & 0x1f) as usize];
2328        v >>= 5;
2329    }
2330    out
2331}
2332
2333pub(crate) fn crockford_decode(s: &str) -> Result<u128, IdParseError> {
2334    let bytes = s.as_bytes();
2335    if bytes.len() != 26 {
2336        return Err(IdParseError::Length { got: bytes.len() });
2337    }
2338    let mut value: u128 = 0;
2339    for (i, byte) in bytes.iter().enumerate() {
2340        let digit = CROCKFORD
2341            .iter()
2342            .position(|c| *c == byte.to_ascii_uppercase())
2343            .ok_or(IdParseError::Char(*byte as char))?;
2344        // 26 chars carry 130 bits. The top character may only use 3 of its 5
2345        // (the ULID overflow rule), so a first digit past 7 cannot fit u128.
2346        if i == 0 && digit > 7 {
2347            return Err(IdParseError::Overflow);
2348        }
2349        value = (value << 5) | digit as u128;
2350    }
2351    Ok(value)
2352}
2353
2354fn cap_str(value: Option<&str>, field: &'static str) -> Result<(), ValidateError> {
2355    if let Some(value) = value
2356        && value.len() > crate::limits::MAX_AGENT_STRING_BYTES
2357    {
2358        return Err(ValidateError::TooLarge {
2359            field,
2360            size: value.len(),
2361            cap: crate::limits::MAX_AGENT_STRING_BYTES,
2362        });
2363    }
2364    Ok(())
2365}
2366
2367// Approximate scalar size in bytes for the metadata caps: text by length,
2368// scalars by their widest encoding, lists by the sum of their elements.
2369fn value_size(value: &Value) -> usize {
2370    match value {
2371        Value::Str(s) => s.len(),
2372        Value::List(items) => items.iter().map(|item| 1 + value_size(item)).sum(),
2373        _ => 9,
2374    }
2375}
2376
2377#[cfg(test)]
2378mod tests {
2379    use super::*;
2380    use crate::validate::Validate;
2381
2382    fn session_start() -> SessionStart {
2383        SessionStart {
2384            label: Some("Research".to_owned()),
2385            namespace: Some("agents".to_owned()),
2386            agent: "planner".parse().expect("valid agent id"),
2387            sdk: SdkInfo {
2388                language: "rust".to_owned(),
2389                version: "0.8.0".to_owned(),
2390            },
2391            parent: None,
2392            root: None,
2393            idle_timeout_micros: Some(300_000_000),
2394            budget: Some(Budget {
2395                tokens: Some(1000),
2396                cost_micros: None,
2397            }),
2398            tags: vec!["demo".to_owned()],
2399        }
2400    }
2401
2402    #[test]
2403    fn given_session_status_when_decoded_then_should_keep_known_and_unknown_values() {
2404        assert_eq!(
2405            serde_json::from_str::<SessionStatus>("\"paused\"").expect("paused"),
2406            SessionStatus::Paused
2407        );
2408        assert_eq!(
2409            serde_json::from_str::<SessionStatus>("\"future\"").expect("unknown"),
2410            SessionStatus::Unrecognized
2411        );
2412    }
2413
2414    #[test]
2415    fn given_session_start_when_validated_then_should_enforce_label_and_tag_caps() {
2416        let mut start = session_start();
2417        start.validate().expect("valid start");
2418        start.label = Some("bad\nlabel".to_owned());
2419        assert!(start.validate().is_err());
2420        start.label = Some("x".repeat(crate::limits::MAX_SESSION_LABEL_BYTES + 1));
2421        assert!(start.validate().is_err());
2422        start.label = None;
2423        start.tags = vec!["tag".to_owned(); crate::limits::MAX_MEMORY_TAGS + 1];
2424        assert!(start.validate().is_err());
2425    }
2426
2427    #[cfg(feature = "cbor")]
2428    #[test]
2429    fn given_session_status_bodies_when_validated_then_should_match_the_lifecycle() {
2430        let mut status = AgentEnvelope::status(
2431            RecordId::from_u128(1),
2432            ConversationId::from_u128(2),
2433            "planner".parse().expect("valid agent id"),
2434            OPERATION_SESSION,
2435        );
2436        status.task_state = Some(TaskState::Submitted);
2437        status.body = crate::framing::encode_named(&session_start()).expect("start encodes");
2438        validate(&status).expect("submitted start");
2439        status.parent = Some(ConversationId::from_u128(8));
2440        assert!(
2441            validate(&status).is_err(),
2442            "start and envelope ancestry must match"
2443        );
2444        status.parent = None;
2445
2446        status.task_state = Some(TaskState::Paused);
2447        status.body = crate::framing::encode_named(&SessionTransition::default())
2448            .expect("transition encodes");
2449        validate(&status).expect("paused transition");
2450
2451        status.task_state = Some(TaskState::Completed);
2452        status.body = crate::framing::encode_named(&SessionEnd::default()).expect("end encodes");
2453        assert!(validate(&status).is_err(), "terminal status needs last");
2454        status.last = true;
2455        validate(&status).expect("completed end");
2456    }
2457
2458    #[cfg(feature = "cbor")]
2459    #[test]
2460    fn given_a_parked_record_when_encoded_then_should_round_trip_and_require_a_generation() {
2461        use crate::validate::Validate;
2462        let source = |generation| crate::graph::SourceRef::Message {
2463            stream: 1,
2464            topic: 2,
2465            partition: 3,
2466            offset: 4,
2467            generation,
2468            conversation: Some(ConversationId::from_u128(5).to_string()),
2469        };
2470        let parking = SessionParking {
2471            source: source(Some(6)),
2472            role: "worker".parse().expect("valid agent id"),
2473            request: LogPosition::new(1, 7, 0, 8),
2474        };
2475        parking.validate().expect("a complete parking is valid");
2476        let bytes = crate::framing::encode_named(&parking).expect("parking encodes");
2477        let decoded: SessionParking = crate::framing::decode_named(&bytes).expect("decodes");
2478        assert_eq!(decoded, parking);
2479        let unproven = SessionParking {
2480            source: source(None),
2481            ..parking.clone()
2482        };
2483        assert!(unproven.validate().is_err());
2484        let not_a_record = SessionParking {
2485            source: crate::graph::SourceRef::Memory { id: "m".to_owned() },
2486            ..parking
2487        };
2488        assert!(not_a_record.validate().is_err());
2489    }
2490
2491    #[test]
2492    fn given_a_pause_request_body_when_decoded_then_should_read_an_empty_body_as_no_participants() {
2493        let empty: SessionPauseRequest = serde_json::from_slice(b"{}").expect("empty decodes");
2494        assert!(empty.participants.is_empty());
2495        let named = SessionPauseRequest {
2496            participants: vec!["worker".parse().expect("valid agent id")],
2497        };
2498        let json = serde_json::to_string(&named).expect("request encodes");
2499        assert_eq!(json, r#"{"participants":["worker"]}"#);
2500        assert_eq!(
2501            serde_json::from_str::<SessionPauseRequest>(&json).expect("decodes"),
2502            named
2503        );
2504    }
2505
2506    #[test]
2507    fn given_an_id_when_displayed_then_should_round_trip_through_crockford_base32() {
2508        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2509        let text = id.to_string();
2510        assert_eq!(text.len(), 26);
2511        assert_eq!(text.parse::<RecordId>().expect("parses"), id);
2512        // Lowercase parses too (Crockford is case-insensitive).
2513        assert_eq!(text.to_lowercase().parse::<RecordId>().expect("parses"), id);
2514        assert_eq!(
2515            RecordId::from_u128(0).to_string(),
2516            "00000000000000000000000000"
2517        );
2518        assert_eq!(
2519            RecordId::from_u128(u128::MAX).to_string(),
2520            "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"
2521        );
2522    }
2523
2524    #[test]
2525    fn given_an_id_in_json_when_encoded_then_should_use_readable_text() {
2526        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2527        let json = serde_json::to_string(&id).expect("serializes");
2528        assert_eq!(json, format!("\"{id}\""));
2529        assert_eq!(
2530            serde_json::from_str::<RecordId>(&json).expect("deserializes"),
2531            id
2532        );
2533        let byte_array = serde_json::to_string(&[0_u8; 16]).expect("serializes byte array");
2534        assert!(serde_json::from_str::<RecordId>(&byte_array).is_err());
2535    }
2536
2537    #[cfg(feature = "cbor")]
2538    #[test]
2539    fn given_an_id_in_named_cbor_when_encoded_then_should_use_fixed_width_bytes() {
2540        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2541        let encoded = crate::framing::encode_named(&id).expect("serializes");
2542        let decoded = crate::framing::decode_named::<RecordId>(&encoded).expect("deserializes");
2543        assert_eq!(decoded, id);
2544    }
2545
2546    #[test]
2547    fn given_invalid_id_strings_when_parsed_then_should_reject_with_the_right_error() {
2548        assert_eq!(
2549            "short".parse::<RecordId>(),
2550            Err(IdParseError::Length { got: 5 })
2551        );
2552        assert_eq!(
2553            "8ZZZZZZZZZZZZZZZZZZZZZZZZZ".parse::<RecordId>(),
2554            Err(IdParseError::Overflow)
2555        );
2556        assert!(matches!(
2557            "UUUUUUUUUUUUUUUUUUUUUUUUUU".parse::<RecordId>(),
2558            Err(IdParseError::Char('U'))
2559        ));
2560    }
2561
2562    #[test]
2563    fn given_agent_id_strings_when_parsed_then_should_accept_printable_and_reject_control() {
2564        for s in ["planner", "planner@acme.example", "team/planner", "a:b"] {
2565            assert_eq!(
2566                s.parse::<AgentId>()
2567                    .expect("a printable agent id is valid")
2568                    .as_str(),
2569                s
2570            );
2571        }
2572        assert!("".parse::<AgentId>().is_err());
2573        assert!("bad\nid".parse::<AgentId>().is_err());
2574    }
2575
2576    #[test]
2577    fn given_task_state_codes_when_mapped_then_should_match_the_pinned_dictionary() {
2578        let expected = [
2579            (TaskState::Submitted, 1u8),
2580            (TaskState::Working, 2),
2581            (TaskState::InputRequired, 3),
2582            (TaskState::Completed, 4),
2583            (TaskState::Canceled, 5),
2584            (TaskState::Failed, 6),
2585            (TaskState::Rejected, 7),
2586            (TaskState::AuthRequired, 8),
2587            (TaskState::Unknown, 9),
2588            (TaskState::Paused, 10),
2589        ];
2590        for (state, code) in expected {
2591            assert_eq!(state.code(), code);
2592            assert_eq!(TaskState::from_code(code), state);
2593        }
2594        // An unknown code decodes and passes through as non-terminal.
2595        let future = TaskState::from_code(42);
2596        assert_eq!(future, TaskState::Unrecognized(42));
2597        assert_eq!(future.code(), 42);
2598        assert!(!future.is_terminal());
2599        assert!(TaskState::Completed.is_terminal());
2600        assert!(!TaskState::Working.is_terminal());
2601        assert!(!TaskState::Paused.is_terminal());
2602        assert_eq!(TaskState::Paused.to_string(), "paused");
2603        assert_eq!("paused".parse::<TaskState>(), Ok(TaskState::Paused));
2604    }
2605
2606    #[test]
2607    fn given_task_state_names_when_round_tripped_then_should_match_the_a2a_vocabulary() {
2608        assert_eq!(TaskState::InputRequired.to_string(), "input-required");
2609        assert_eq!(
2610            "auth-required".parse::<TaskState>().expect("parses"),
2611            TaskState::AuthRequired
2612        );
2613        assert!("nope".parse::<TaskState>().is_err());
2614    }
2615
2616    #[test]
2617    fn given_error_and_dead_letter_codes_when_mapped_then_should_match_the_dictionaries() {
2618        assert_eq!(AgentErrorCode::InvalidRequest.code(), 1);
2619        assert_eq!(AgentErrorCode::Internal.code(), 7);
2620        assert_eq!(
2621            AgentErrorCode::from_code(99),
2622            AgentErrorCode::Unrecognized(99)
2623        );
2624        assert_eq!(DeadLetterReason::RetryExhausted.code(), 1);
2625        assert_eq!(DeadLetterReason::DeadlineExceeded.code(), 4);
2626        assert_eq!(
2627            DeadLetterReason::from_code(77),
2628            DeadLetterReason::Unrecognized(77)
2629        );
2630    }
2631
2632    #[test]
2633    fn given_every_u8_when_mapped_through_the_dictionaries_then_the_code_should_round_trip() {
2634        // Totality: from_code is defined on the whole u8 space (unknown codes
2635        // become Unrecognized), and code() is its left inverse, so no byte can
2636        // panic or be lost on decode.
2637        for code in 0u8..=u8::MAX {
2638            assert_eq!(TaskState::from_code(code).code(), code);
2639            assert_eq!(AgentErrorCode::from_code(code).code(), code);
2640            assert_eq!(DeadLetterReason::from_code(code).code(), code);
2641        }
2642    }
2643
2644    #[test]
2645    fn given_an_idempotency_key_when_validated_then_should_enforce_the_cap() {
2646        assert!("job-123-attempt-2".parse::<IdempotencyKey>().is_ok());
2647        assert!("".parse::<IdempotencyKey>().is_err());
2648        assert!("x".repeat(65).parse::<IdempotencyKey>().is_err());
2649    }
2650
2651    #[test]
2652    fn given_an_idempotency_key_with_a_control_character_when_parsed_then_should_refuse_it() {
2653        assert!("job-123\nattempt-2".parse::<IdempotencyKey>().is_err());
2654        assert!("job-123\u{7f}".parse::<IdempotencyKey>().is_err());
2655        assert!(
2656            serde_json::from_str::<IdempotencyKey>("\"job\\u0000\"").is_err(),
2657            "a decoded key is checked too"
2658        );
2659    }
2660
2661    #[test]
2662    fn given_a_command_when_validated_then_should_pass_and_enforce_the_matrix() {
2663        let (record, conversation, source, correlation) = ids();
2664        let command =
2665            AgentEnvelope::command(record, conversation, source, correlation, b"do".to_vec());
2666        validate(&command).expect("a well-formed command validates");
2667
2668        // A command without correlation is an event wearing the wrong kind.
2669        let mut missing = command.clone();
2670        missing.correlation = None;
2671        assert_eq!(
2672            validate(&missing),
2673            Err(ValidateError::Missing {
2674                kind: AgentKind::Command,
2675                field: "correlation"
2676            })
2677        );
2678
2679        // usage is X on commands (accounting rides replies and terminals).
2680        let with_usage = command.clone().with_usage(TokenUsage::default());
2681        assert!(matches!(
2682            validate(&with_usage),
2683            Err(ValidateError::Forbidden { field: "usage", .. })
2684        ));
2685
2686        // channel is chunk identity, invalid elsewhere.
2687        let mut with_channel = command;
2688        with_channel.channel = Some(ChannelId::from_u128(1));
2689        assert!(matches!(
2690            validate(&with_channel),
2691            Err(ValidateError::Forbidden {
2692                field: "channel",
2693                ..
2694            })
2695        ));
2696    }
2697
2698    #[test]
2699    fn given_chunks_when_validated_then_should_enforce_stream_semantics() {
2700        let (_, conversation, source, correlation) = ids();
2701        let channel = ChannelId::from_u128(23);
2702        let chunk = AgentEnvelope::chunk(
2703            conversation,
2704            source.clone(),
2705            correlation,
2706            channel,
2707            0,
2708            b"tok".to_vec(),
2709        )
2710        .with_operation(OPERATION_CHAT);
2711        validate(&chunk).expect("a stream chunk validates");
2712
2713        // The opening chunk REQUIRES its purpose, from the pinned vocabulary.
2714        let mut undeclared = chunk.clone();
2715        undeclared.operation = None;
2716        assert!(matches!(
2717            validate(&undeclared),
2718            Err(ValidateError::Missing {
2719                field: "operation",
2720                ..
2721            })
2722        ));
2723        let mut off_vocabulary = chunk.clone();
2724        off_vocabulary.operation = Some("telemetry".to_owned());
2725        assert!(matches!(
2726            validate(&off_vocabulary),
2727            Err(ValidateError::Invalid {
2728                field: "operation",
2729                ..
2730            })
2731        ));
2732
2733        // The purpose rides ONLY the opening chunk.
2734        let redeclared = AgentEnvelope::chunk(
2735            conversation,
2736            source.clone(),
2737            correlation,
2738            channel,
2739            3,
2740            b"tok".to_vec(),
2741        )
2742        .with_operation(OPERATION_REASONING);
2743        assert!(matches!(
2744            validate(&redeclared),
2745            Err(ValidateError::Invalid {
2746                field: "operation",
2747                ..
2748            })
2749        ));
2750
2751        // The terminal chunk may be empty and carries finish_reason + usage.
2752        let terminal = AgentEnvelope::chunk(
2753            conversation,
2754            source.clone(),
2755            correlation,
2756            channel,
2757            41,
2758            Vec::new(),
2759        )
2760        .terminal("stop")
2761        .with_usage(TokenUsage {
2762            input_tokens: 100,
2763            output_tokens: 42,
2764            ..Default::default()
2765        });
2766        validate(&terminal).expect("a terminal chunk validates");
2767
2768        // A non-terminal chunk cannot carry finish_reason or usage.
2769        let mut early_finish = chunk.clone();
2770        early_finish.finish_reason = Some("stop".to_owned());
2771        assert!(matches!(
2772            validate(&early_finish),
2773            Err(ValidateError::Invalid {
2774                field: "finish_reason",
2775                ..
2776            })
2777        ));
2778
2779        // An empty non-terminal chunk carries nothing.
2780        let empty = AgentEnvelope::chunk(
2781            conversation,
2782            source.clone(),
2783            correlation,
2784            channel,
2785            1,
2786            Vec::new(),
2787        );
2788        assert!(matches!(
2789            validate(&empty),
2790            Err(ValidateError::Missing { field: "body", .. })
2791        ));
2792
2793        // Chunks carry no dedup semantics.
2794        let mut keyed = chunk.clone();
2795        keyed.idempotency_key = Some("k".parse().expect("valid key"));
2796        assert!(matches!(
2797            validate(&keyed),
2798            Err(ValidateError::Forbidden {
2799                field: "idempotency_key",
2800                ..
2801            })
2802        ));
2803
2804        // The abandonment bound rides only the opening chunk (sequence 0).
2805        let opening = chunk.with_deadline_micros(1);
2806        validate(&opening).expect("an opening chunk may declare the bound");
2807        let late = AgentEnvelope::chunk(
2808            conversation,
2809            source.clone(),
2810            correlation,
2811            channel,
2812            5,
2813            b"tok".to_vec(),
2814        )
2815        .with_deadline_micros(1);
2816        assert!(matches!(
2817            validate(&late),
2818            Err(ValidateError::Invalid {
2819                field: "deadline_micros",
2820                ..
2821            })
2822        ));
2823    }
2824
2825    #[test]
2826    fn given_status_signals_when_validated_then_task_updates_should_require_state() {
2827        let (record, conversation, source, correlation) = ids();
2828        // A liveness card: no correlation, no task_state.
2829        let card = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_CARD);
2830        validate(&card).expect("a card validates");
2831
2832        // A task update requires correlation + task_state.
2833        let bare_task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK);
2834        assert!(matches!(
2835            validate(&bare_task),
2836            Err(ValidateError::Missing {
2837                field: "correlation",
2838                ..
2839            })
2840        ));
2841        let task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK)
2842            .with_correlation(correlation)
2843            .with_task_state(TaskState::Working);
2844        validate(&task).expect("a task update validates");
2845
2846        // The discriminator is a CLOSED vocabulary: task | card | progress.
2847        let off_vocabulary =
2848            AgentEnvelope::status(record, conversation, source.clone(), "telemetry");
2849        assert!(matches!(
2850            validate(&off_vocabulary),
2851            Err(ValidateError::Invalid {
2852                field: "operation",
2853                ..
2854            })
2855        ));
2856    }
2857
2858    #[test]
2859    fn given_an_error_when_validated_then_last_should_be_forbidden() {
2860        let (record, conversation, source, correlation) = ids();
2861        let error =
2862            AgentEnvelope::error(record, conversation, source, correlation, b"boom".to_vec());
2863        validate(&error).expect("an error validates");
2864
2865        // error is ALWAYS terminal, so the flag would be redundant.
2866        let mut flagged = error.clone();
2867        flagged.last = true;
2868        assert!(matches!(
2869            validate(&flagged),
2870            Err(ValidateError::Forbidden { field: "last", .. })
2871        ));
2872
2873        // sequence without channel is incoherent.
2874        let mut dangling = error;
2875        dangling.sequence = Some(3);
2876        assert!(matches!(
2877            validate(&dangling),
2878            Err(ValidateError::Invalid {
2879                field: "sequence",
2880                ..
2881            })
2882        ));
2883    }
2884
2885    fn descriptor(skill_id: &str) -> CapabilityDescriptor {
2886        CapabilityDescriptor {
2887            skill_id: skill_id.to_owned(),
2888            input: None,
2889            output: None,
2890            cost_class: None,
2891            latency_class: None,
2892            max_concurrency: None,
2893            health: None,
2894            load: None,
2895        }
2896    }
2897
2898    #[test]
2899    fn given_an_agent_card_when_validated_then_should_enforce_the_caps() {
2900        let card = AgentCard {
2901            name: Some("rollout-planner".to_owned()),
2902            version: Some("1.4.2".to_owned()),
2903            capabilities: vec![
2904                CapabilityDescriptor {
2905                    skill_id: "chat".to_owned(),
2906                    input: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2907                    output: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2908                    cost_class: Some(2),
2909                    latency_class: Some(1),
2910                    max_concurrency: Some(8),
2911                    health: Some(Health::Healthy),
2912                    load: Some(250),
2913                },
2914                descriptor("plan_rollout"),
2915            ],
2916            ttl_micros: Some(30_000_000),
2917        };
2918        card.validate().expect("a well-formed card validates");
2919
2920        let mut crowded = card.clone();
2921        crowded.capabilities = vec![descriptor("x"); crate::limits::MAX_CARD_CAPABILITIES + 1];
2922        assert!(matches!(
2923            crowded.validate(),
2924            Err(ValidateError::TooLarge {
2925                field: "capabilities",
2926                ..
2927            })
2928        ));
2929
2930        let mut oversized = card;
2931        oversized.name = Some("n".repeat(crate::limits::MAX_AGENT_STRING_BYTES + 1));
2932        assert!(matches!(
2933            oversized.validate(),
2934            Err(ValidateError::TooLarge { field: "name", .. })
2935        ));
2936    }
2937
2938    #[cfg(feature = "cbor")]
2939    #[test]
2940    fn given_a_schema_id_named_like_a_content_type_when_round_tripped_then_should_stay_a_schema_id()
2941    {
2942        use crate::framing::{decode_named, encode_named};
2943        // The tag disambiguates a schema id that happens to be a content-type
2944        // name: an untagged encoding would decode this as ContentType::Json.
2945        let reference = ContentRef::SchemaId("json".to_owned());
2946        let bytes = encode_named(&reference).expect("encodes");
2947        let back: ContentRef = decode_named(&bytes).expect("decodes");
2948        assert_eq!(back, ContentRef::SchemaId("json".to_owned()));
2949        // A genuine content-type still round-trips as one.
2950        let ct = ContentRef::ContentType(crate::content::ContentType::Json);
2951        let back: ContentRef = decode_named(&encode_named(&ct).expect("encodes")).expect("decodes");
2952        assert_eq!(back, ct);
2953    }
2954
2955    #[test]
2956    fn given_a_signature_when_validated_then_should_enforce_per_scheme_lengths() {
2957        let valid = Signature {
2958            scheme: SIGNATURE_SCHEME_ED25519,
2959            key_id: vec![1u8; 8],
2960            bytes: vec![2u8; 64],
2961            context: None,
2962        };
2963        valid
2964            .validate()
2965            .expect("a well-formed Ed25519 signature validates");
2966
2967        let mut short_key = valid.clone();
2968        short_key.key_id = vec![1u8; 4];
2969        assert!(matches!(
2970            short_key.validate(),
2971            Err(ValidateError::Invalid {
2972                field: "key_id",
2973                ..
2974            })
2975        ));
2976
2977        let mut short_signature = valid.clone();
2978        short_signature.bytes = vec![2u8; 32];
2979        assert!(matches!(
2980            short_signature.validate(),
2981            Err(ValidateError::Invalid { field: "bytes", .. })
2982        ));
2983
2984        // Unknown scheme codes pass through (the open-dictionary rule).
2985        // Verification is SDK-side and scheme-aware.
2986        let future = Signature {
2987            scheme: 42,
2988            key_id: vec![1u8; 3],
2989            bytes: vec![2u8; 99],
2990            context: None,
2991        };
2992        future.validate().expect("an unknown scheme flows through");
2993    }
2994
2995    #[test]
2996    fn given_a_body_ref_when_validated_then_should_enforce_reference_and_digest() {
2997        let valid = BodyRef::new("s3://transcripts/conv-1/msg-9", 4_194_304, [7u8; 32]);
2998        valid.validate().expect("a well-formed reference validates");
2999
3000        let mut empty = valid.clone();
3001        empty.reference = String::new();
3002        assert!(matches!(
3003            empty.validate(),
3004            Err(ValidateError::Invalid {
3005                field: "reference",
3006                ..
3007            })
3008        ));
3009
3010        let mut oversized = valid.clone();
3011        oversized.reference = "x".repeat(crate::limits::MAX_BODY_REFERENCE_BYTES + 1);
3012        assert!(matches!(
3013            oversized.validate(),
3014            Err(ValidateError::TooLarge {
3015                field: "reference",
3016                ..
3017            })
3018        ));
3019
3020        let mut truncated = valid;
3021        truncated.sha256 = vec![7u8; 16];
3022        assert!(matches!(
3023            truncated.validate(),
3024            Err(ValidateError::Invalid {
3025                field: "sha256",
3026                ..
3027            })
3028        ));
3029    }
3030
3031    #[test]
3032    fn given_oversized_metadata_when_validated_then_should_reject() {
3033        let (record, conversation, source, correlation) = ids();
3034        let mut command =
3035            AgentEnvelope::command(record, conversation, source, correlation, b"x".to_vec());
3036        for i in 0..crate::limits::MAX_METADATA_ENTRIES + 1 {
3037            command = command.with_metadata(format!("k{i}"), i as i64);
3038        }
3039        assert!(matches!(
3040            validate(&command),
3041            Err(ValidateError::TooLarge {
3042                field: "metadata",
3043                ..
3044            })
3045        ));
3046    }
3047
3048    fn ids() -> (RecordId, ConversationId, AgentId, CorrelationId) {
3049        (
3050            RecordId::from_u128(7),
3051            ConversationId::from_u128(11),
3052            "test-agent".parse().expect("valid agent id"),
3053            CorrelationId::from_u128(17),
3054        )
3055    }
3056}
3057
3058#[cfg(all(test, feature = "cbor"))]
3059mod wire_tests {
3060    use super::*;
3061    use crate::framing::{decode_named, encode_named};
3062
3063    #[test]
3064    fn given_a_wrong_length_locator_when_decoded_then_should_error_not_panic() {
3065        // CBOR byte string: 0x40 | len for lengths below 24. Exactly 20 bytes
3066        // is the only valid locator. Anything else is a clean decode error.
3067        let mut valid = vec![0x40 | 20];
3068        valid.extend_from_slice(&[0u8; 20]);
3069        decode_named::<LogPosition>(&valid).expect("20 packed bytes decode");
3070
3071        for bad_len in [0u8, 19, 21, 23] {
3072            let mut bytes = vec![0x40 | bad_len];
3073            bytes.extend_from_slice(&vec![0u8; bad_len as usize]);
3074            assert!(
3075                decode_named::<LogPosition>(&bytes).is_err(),
3076                "a {bad_len}-byte locator must error, not panic"
3077            );
3078        }
3079    }
3080
3081    #[test]
3082    fn given_a_locator_when_round_tripped_then_should_preserve_every_field() {
3083        let pos = LogPosition::new(0x0102_0304, 0x0506_0708, 0x090A_0B0C, 0x0D0E_0F10_1112_1314);
3084        let bytes = encode_named(&pos).expect("encodes");
3085        let back: LogPosition = decode_named(&bytes).expect("decodes");
3086        assert_eq!(back, pos);
3087    }
3088
3089    #[test]
3090    fn given_an_envelope_when_round_tripped_then_should_preserve_every_field() {
3091        let envelope = AgentEnvelope::command(
3092            RecordId::from_u128(1),
3093            ConversationId::from_u128(2),
3094            "source-agent".parse().expect("valid agent id"),
3095            CorrelationId::from_u128(4),
3096            b"payload".to_vec(),
3097        )
3098        .with_target("target-agent".parse().expect("valid agent id"))
3099        .with_cause(RecordId::from_u128(6), Some(LogPosition::new(1, 2, 3, 44)))
3100        .with_idempotency_key("job-1".parse().expect("valid key"))
3101        .with_deadline_micros(1_700_000_000_000_000)
3102        .with_operation("chat")
3103        .with_tool("search")
3104        .with_metadata("pool", "eu-west");
3105        let bytes = encode_named(&envelope).expect("encodes");
3106        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3107        assert_eq!(back, envelope);
3108    }
3109
3110    #[test]
3111    fn given_a_must_understand_marker_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
3112        // A receiver lacking a required bit must see it as unmet, and the
3113        // default-zero marker is omitted on the wire so pre-marker records stay
3114        // byte-identical.
3115        let envelope = AgentEnvelope::event(
3116            RecordId::from_u128(1),
3117            ConversationId::from_u128(2),
3118            "source-agent".parse().expect("valid agent id"),
3119            b"e".to_vec(),
3120        )
3121        .requiring(0b101);
3122        let bytes = encode_named(&envelope).expect("encodes");
3123        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3124        assert_eq!(back.must_understand, 0b101);
3125        // A receiver that understands bit 0 but not bit 2 has bit 2 unmet.
3126        assert_eq!(back.unmet_requirements(0b001), 0b100);
3127        // A receiver that understands both has nothing unmet.
3128        assert_eq!(back.unmet_requirements(0b111), 0);
3129        // The zero marker (open-world default) is unmet by nobody and omitted.
3130        let plain = AgentEnvelope::event(
3131            RecordId::from_u128(1),
3132            ConversationId::from_u128(2),
3133            "source-agent".parse().expect("valid agent id"),
3134            b"e".to_vec(),
3135        );
3136        assert_eq!(plain.unmet_requirements(features::NONE), 0);
3137        let json = serde_json::to_string(&plain).expect("json");
3138        assert!(
3139            !json.contains("must_understand"),
3140            "zero marker must be omitted: {json}"
3141        );
3142    }
3143
3144    #[test]
3145    fn given_absent_options_when_encoded_then_should_cost_zero_bytes() {
3146        // The skip-serializing discipline: a minimal event encodes only its
3147        // five present fields (kind, record, conversation, source, body).
3148        let envelope = AgentEnvelope::event(
3149            RecordId::from_u128(1),
3150            ConversationId::from_u128(2),
3151            "source-agent".parse().expect("valid agent id"),
3152            b"e".to_vec(),
3153        );
3154        let bytes = encode_named(&envelope).expect("encodes");
3155        // A CBOR map with at most 15 fields encodes its count in the head byte's
3156        // low nibble (major type 5, `0xa0 | count`).
3157        assert_eq!(bytes[0] & 0x0f, 5, "absent optionals must not be encoded");
3158        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3159        assert!(!back.last);
3160        assert!(back.metadata.is_none());
3161    }
3162
3163    #[test]
3164    fn given_an_id_when_encoded_then_should_ride_as_one_fixed_width_byte_string() {
3165        let bytes = encode_named(&RecordId::from_u128(0x0102)).expect("encodes");
3166        // CBOR byte-string head `0x50` (major type 2, length 16), then the 16
3167        // big-endian bytes, fixed width, no bignum tag. 17 bytes total.
3168        assert_eq!(bytes.len(), 17);
3169        assert_eq!(bytes[0], 0x50);
3170        assert_eq!(bytes[16], 0x02);
3171        assert_eq!(bytes[15], 0x01);
3172    }
3173
3174    #[test]
3175    fn given_a_task_state_when_encoded_then_should_ride_as_a_bare_u8() {
3176        let bytes = encode_named(&TaskState::Completed).expect("encodes");
3177        assert_eq!(bytes, vec![4]);
3178        let unknown = encode_named(&42u8).expect("encodes a raw code");
3179        let back: TaskState = decode_named(&unknown).expect("unknown code decodes");
3180        assert_eq!(back, TaskState::Unrecognized(42));
3181    }
3182
3183    #[test]
3184    fn given_an_error_body_when_round_tripped_then_should_preserve_the_dictionary_code() {
3185        let body = AgentErrorBody {
3186            code: AgentErrorCode::ToolFailure,
3187            message: Some("search timed out".to_owned()),
3188            retryable: true,
3189            detail: Some(BTreeMap::from([("attempt".to_owned(), Value::Int(3))])),
3190        };
3191        let bytes = encode_named(&body).expect("encodes");
3192        let back: AgentErrorBody = decode_named(&bytes).expect("decodes");
3193        assert_eq!(back, body);
3194    }
3195
3196    #[test]
3197    fn given_a_body_ref_when_round_tripped_then_should_preserve_the_digest_as_a_byte_string() {
3198        let capsule = BodyRef::new("kv://bodies/abc", 1024, [9u8; 32]);
3199        let bytes = encode_named(&capsule).expect("encodes");
3200        let back: BodyRef = decode_named(&bytes).expect("decodes");
3201        assert_eq!(back, capsule);
3202        assert!(back.encryption.is_none(), "absent encryption costs nothing");
3203        back.validate().expect("decoded capsule validates");
3204    }
3205
3206    #[test]
3207    fn given_a_dead_letter_when_round_tripped_then_payload_should_stay_byte_identical() {
3208        let inner = AgentEnvelope::command(
3209            RecordId::from_u128(9),
3210            ConversationId::from_u128(8),
3211            "source-agent".parse().expect("valid agent id"),
3212            CorrelationId::from_u128(6),
3213            b"poison".to_vec(),
3214        );
3215        let payload = encode_named(&inner).expect("inner encodes");
3216        let capsule = AgentDeadLetter {
3217            source: LogPosition::new(1, 2, 3, 99),
3218            reason: DeadLetterReason::RetryExhausted,
3219            attempts: 5,
3220            detail: Some("handler kept failing".to_owned()),
3221            payload: payload.clone(),
3222        };
3223        let bytes = encode_named(&capsule).expect("encodes");
3224        let back: AgentDeadLetter = decode_named(&bytes).expect("decodes");
3225        assert_eq!(back.payload, payload, "redrive needs the original bytes");
3226        let redrive: AgentEnvelope = decode_named(&back.payload).expect("inner decodes");
3227        assert_eq!(redrive, inner);
3228    }
3229    #[test]
3230    fn given_a_task_status_when_validated_then_should_require_the_correlation() {
3231        // A task status must carry a correlation.
3232        let base = AgentEnvelope::status(
3233            RecordId::from_u128(1),
3234            ConversationId::from_u128(2),
3235            "runner".parse().expect("valid agent id"),
3236            OPERATION_TASK,
3237        )
3238        .with_task_state(TaskState::Working);
3239        assert!(
3240            matches!(
3241                validate(&base),
3242                Err(ValidateError::Missing {
3243                    kind: AgentKind::Status,
3244                    field: "correlation"
3245                })
3246            ),
3247            "a task status without a correlation is rejected"
3248        );
3249        let correlated = base.with_correlation(CorrelationId::from_u128(2));
3250        validate(&correlated)
3251            .expect("the run-lifecycle status validates once it correlates on the run");
3252    }
3253
3254    #[test]
3255    fn given_task_states_when_displayed_and_parsed_then_should_use_the_pinned_kebab_words() {
3256        assert_eq!(TaskState::InputRequired.to_string(), "input-required");
3257        assert_eq!(TaskState::AuthRequired.to_string(), "auth-required");
3258        assert_eq!(TaskState::Unrecognized(42).to_string(), "unrecognized-42");
3259        assert_eq!(
3260            "input-required".parse::<TaskState>().expect("parses"),
3261            TaskState::InputRequired
3262        );
3263        assert!("bogus".parse::<TaskState>().is_err());
3264    }
3265}