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        Ok(Self(value))
367    }
368}
369
370impl From<IdempotencyKey> for String {
371    fn from(value: IdempotencyKey) -> Self {
372        value.0
373    }
374}
375
376impl fmt::Display for IdempotencyKey {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        f.write_str(&self.0)
379    }
380}
381
382/// An agent's identity: a bounded, human-readable name string.
383///
384/// A string rather than an opaque numeric id, because an agent is a named
385/// principal every edge protocol spells out as text (an A2A agent name or URL,
386/// an MCP server name, OTel `gen_ai.agent.id`). The SDK's named agents map
387/// straight onto it with no lossy hash. It is an authorship claim on shared
388/// topics (see the module docs). Non-empty, at most
389/// [`MAX_AGENT_STRING_BYTES`](crate::limits::MAX_AGENT_STRING_BYTES), and free
390/// of ASCII control characters. Every other character is allowed.
391#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
392#[serde(try_from = "String", into = "String")]
393pub struct AgentId(String);
394
395impl AgentId {
396    /// The id as a string slice.
397    pub fn as_str(&self) -> &str {
398        &self.0
399    }
400}
401
402impl FromStr for AgentId {
403    type Err = InvalidError;
404
405    fn from_str(s: &str) -> Result<Self, Self::Err> {
406        s.to_owned().try_into()
407    }
408}
409
410impl TryFrom<String> for AgentId {
411    type Error = InvalidError;
412
413    fn try_from(value: String) -> Result<Self, Self::Error> {
414        if value.is_empty() {
415            return Err(InvalidError::new("agent id must not be empty"));
416        }
417        if value.len() > crate::limits::MAX_AGENT_STRING_BYTES {
418            return Err(InvalidError::new(format!(
419                "agent id is {}B, exceeds cap {}B",
420                value.len(),
421                crate::limits::MAX_AGENT_STRING_BYTES
422            )));
423        }
424        if let Some(c) = value.chars().find(|c| c.is_control()) {
425            return Err(InvalidError::new(format!(
426                "agent id must not contain control characters (found {c:?})"
427            )));
428        }
429        Ok(Self(value))
430    }
431}
432
433impl TryFrom<&str> for AgentId {
434    type Error = InvalidError;
435
436    fn try_from(value: &str) -> Result<Self, Self::Error> {
437        value.to_owned().try_into()
438    }
439}
440
441impl From<AgentId> for String {
442    fn from(value: AgentId) -> Self {
443        value.0
444    }
445}
446
447impl fmt::Display for AgentId {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        f.write_str(&self.0)
450    }
451}
452
453/// What a message is. A closed vocabulary by design: adding a kind requires an
454/// `AGENT_OP_VERSION` bump and a hello advertisement, because an unknown kind
455/// must fail decode rather than flow misinterpreted.
456#[derive(
457    Clone,
458    Copy,
459    Debug,
460    PartialEq,
461    Eq,
462    Hash,
463    Serialize,
464    Deserialize,
465    strum::Display,
466    strum::EnumString,
467)]
468#[serde(rename_all = "snake_case")]
469#[strum(serialize_all = "snake_case")]
470pub enum AgentKind {
471    /// Expects a reply or effect. Requires `correlation`. Fire-and-forget
472    /// commands do not exist by definition. Those are events.
473    Command,
474    /// The paired answer to a command.
475    Response,
476    /// Expects nothing.
477    Event,
478    /// One piece of a stream, ordered by `sequence` within a `channel`.
479    Chunk,
480    /// Lifecycle signal, discriminated by `operation`: task updates (`task`),
481    /// liveness cards (`card`), and progress ticks (`progress`).
482    Status,
483    /// A terminal failure. The body is a structured [`AgentErrorBody`].
484    Error,
485}
486
487/// A2A's task lifecycle, adopted verbatim, riding the wire as a u8 code (the
488/// `agdx.ct` dictionary pattern) so a future A2A state takes the next free code
489/// and flows through old consumers as an opaque non-terminal value instead of
490/// forcing a version bump on someone else's release schedule. Codes are
491/// permanent and never renumbered.
492// Display and FromStr are hand-written, not strum-derived, deliberately: the
493// `Unrecognized(u8)` passthrough variant must format its carried code
494// (`unrecognized-42`) and must never parse from text, a combination strum's
495// per-variant attributes cannot express (`disabled` removes the variant from
496// Display too and the derive panics on it). Every pure unit-word vocabulary
497// (`AgentRunState`, `ForkKind`, `ContentType`, ...) derives strum instead.
498#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
499#[serde(from = "u8", into = "u8")]
500pub enum TaskState {
501    Submitted,
502    Working,
503    InputRequired,
504    Completed,
505    Canceled,
506    Failed,
507    Rejected,
508    AuthRequired,
509    Unknown,
510    /// A code this build does not know: passed through, treated as non-terminal.
511    Unrecognized(u8),
512}
513
514impl TaskState {
515    /// The pinned wire code.
516    pub const fn code(self) -> u8 {
517        match self {
518            TaskState::Submitted => 1,
519            TaskState::Working => 2,
520            TaskState::InputRequired => 3,
521            TaskState::Completed => 4,
522            TaskState::Canceled => 5,
523            TaskState::Failed => 6,
524            TaskState::Rejected => 7,
525            TaskState::AuthRequired => 8,
526            TaskState::Unknown => 9,
527            TaskState::Unrecognized(code) => code,
528        }
529    }
530
531    /// The state for a wire code (total: unknown codes become
532    /// [`Unrecognized`](Self::Unrecognized)).
533    pub const fn from_code(code: u8) -> Self {
534        match code {
535            1 => TaskState::Submitted,
536            2 => TaskState::Working,
537            3 => TaskState::InputRequired,
538            4 => TaskState::Completed,
539            5 => TaskState::Canceled,
540            6 => TaskState::Failed,
541            7 => TaskState::Rejected,
542            8 => TaskState::AuthRequired,
543            9 => TaskState::Unknown,
544            other => TaskState::Unrecognized(other),
545        }
546    }
547
548    /// Whether this state ends the task (A2A's terminal set). Unrecognized
549    /// codes are non-terminal by rule.
550    pub const fn is_terminal(self) -> bool {
551        matches!(
552            self,
553            TaskState::Completed | TaskState::Canceled | TaskState::Failed | TaskState::Rejected
554        )
555    }
556}
557
558impl From<u8> for TaskState {
559    fn from(code: u8) -> Self {
560        Self::from_code(code)
561    }
562}
563
564impl From<TaskState> for u8 {
565    fn from(state: TaskState) -> u8 {
566        state.code()
567    }
568}
569
570impl fmt::Display for TaskState {
571    // The A2A kebab-case names, for bridges and consoles.
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573        match self {
574            TaskState::Submitted => f.write_str("submitted"),
575            TaskState::Working => f.write_str("working"),
576            TaskState::InputRequired => f.write_str("input-required"),
577            TaskState::Completed => f.write_str("completed"),
578            TaskState::Canceled => f.write_str("canceled"),
579            TaskState::Failed => f.write_str("failed"),
580            TaskState::Rejected => f.write_str("rejected"),
581            TaskState::AuthRequired => f.write_str("auth-required"),
582            TaskState::Unknown => f.write_str("unknown"),
583            TaskState::Unrecognized(code) => write!(f, "unrecognized-{code}"),
584        }
585    }
586}
587
588impl FromStr for TaskState {
589    type Err = InvalidError;
590
591    fn from_str(s: &str) -> Result<Self, Self::Err> {
592        Ok(match s {
593            "submitted" => TaskState::Submitted,
594            "working" => TaskState::Working,
595            "input-required" => TaskState::InputRequired,
596            "completed" => TaskState::Completed,
597            "canceled" => TaskState::Canceled,
598            "failed" => TaskState::Failed,
599            "rejected" => TaskState::Rejected,
600            "auth-required" => TaskState::AuthRequired,
601            "unknown" => TaskState::Unknown,
602            other => return Err(InvalidError::new(format!("unknown task state `{other}`"))),
603        })
604    }
605}
606
607/// Typed token accounting, OTel-aligned (`gen_ai.usage.*`, the current
608/// `input_tokens` and `output_tokens` names, never the deprecated
609/// prompt/completion pair).
610///
611/// Advisory analytics input. It is agent-written, so budgets enforce where the
612/// LLM call executes, never on this field.
613#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
614pub struct TokenUsage {
615    pub input_tokens: u64,
616    pub output_tokens: u64,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub reasoning_output_tokens: Option<u64>,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub cache_read_input_tokens: Option<u64>,
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub cache_creation_input_tokens: Option<u64>,
623}
624
625/// Why an agent operation failed, as a pinned u8 dictionary (the
626/// [`TaskState`] pattern: unknown codes decode and pass through).
627#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
628#[serde(from = "u8", into = "u8")]
629pub enum AgentErrorCode {
630    InvalidRequest,
631    Unauthorized,
632    Unsupported,
633    DeadlineExceeded,
634    Cancelled,
635    ToolFailure,
636    Internal,
637    /// A code this build does not know: pass it through.
638    Unrecognized(u8),
639}
640
641impl AgentErrorCode {
642    /// The pinned wire code.
643    pub const fn code(self) -> u8 {
644        match self {
645            AgentErrorCode::InvalidRequest => 1,
646            AgentErrorCode::Unauthorized => 2,
647            AgentErrorCode::Unsupported => 3,
648            AgentErrorCode::DeadlineExceeded => 4,
649            AgentErrorCode::Cancelled => 5,
650            AgentErrorCode::ToolFailure => 6,
651            AgentErrorCode::Internal => 7,
652            AgentErrorCode::Unrecognized(code) => code,
653        }
654    }
655
656    /// The error code for a wire code (total).
657    pub const fn from_code(code: u8) -> Self {
658        match code {
659            1 => AgentErrorCode::InvalidRequest,
660            2 => AgentErrorCode::Unauthorized,
661            3 => AgentErrorCode::Unsupported,
662            4 => AgentErrorCode::DeadlineExceeded,
663            5 => AgentErrorCode::Cancelled,
664            6 => AgentErrorCode::ToolFailure,
665            7 => AgentErrorCode::Internal,
666            other => AgentErrorCode::Unrecognized(other),
667        }
668    }
669}
670
671impl From<u8> for AgentErrorCode {
672    fn from(code: u8) -> Self {
673        Self::from_code(code)
674    }
675}
676
677impl From<AgentErrorCode> for u8 {
678    fn from(code: AgentErrorCode) -> u8 {
679        code.code()
680    }
681}
682
683/// The structured body of a `kind = error` envelope, mirroring the wire error
684/// enums of the other surfaces. The `code` is the machine discriminator, the
685/// optional `message` is human detail.
686#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
687pub struct AgentErrorBody {
688    pub code: AgentErrorCode,
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub message: Option<String>,
691    #[serde(default)]
692    pub retryable: bool,
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub detail: Option<BTreeMap<String, Value>>,
695}
696
697/// Why a message was dead-lettered, as a pinned u8 dictionary.
698#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
699#[serde(from = "u8", into = "u8")]
700pub enum DeadLetterReason {
701    RetryExhausted,
702    Rejected,
703    DecodeFailed,
704    DeadlineExceeded,
705    /// A code this build does not know: pass it through.
706    Unrecognized(u8),
707}
708
709impl DeadLetterReason {
710    /// The pinned wire code.
711    pub const fn code(self) -> u8 {
712        match self {
713            DeadLetterReason::RetryExhausted => 1,
714            DeadLetterReason::Rejected => 2,
715            DeadLetterReason::DecodeFailed => 3,
716            DeadLetterReason::DeadlineExceeded => 4,
717            DeadLetterReason::Unrecognized(code) => code,
718        }
719    }
720
721    /// The reason for a wire code (total).
722    pub const fn from_code(code: u8) -> Self {
723        match code {
724            1 => DeadLetterReason::RetryExhausted,
725            2 => DeadLetterReason::Rejected,
726            3 => DeadLetterReason::DecodeFailed,
727            4 => DeadLetterReason::DeadlineExceeded,
728            other => DeadLetterReason::Unrecognized(other),
729        }
730    }
731}
732
733impl From<u8> for DeadLetterReason {
734    fn from(code: u8) -> Self {
735        Self::from_code(code)
736    }
737}
738
739impl From<DeadLetterReason> for u8 {
740    fn from(reason: DeadLetterReason) -> u8 {
741        reason.code()
742    }
743}
744
745/// The agent-level dead-letter capsule: the poison message's log position, the
746/// reason, and the original payload verbatim.
747///
748/// The payload is the encoded [`AgentEnvelope`], byte-identical, so redrive is
749/// trivially correct. Republish it to the source topic, or inspect it by
750/// decoding the inner envelope. The capsule rides a dedicated dead-letter topic
751/// in the agent stream, CBOR like everything else in AGDX. LaserData Cloud's own
752/// dead-letter capsules stay operator-facing JSON on a different topic, for a
753/// different audience.
754#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
755pub struct AgentDeadLetter {
756    pub source: LogPosition,
757    pub reason: DeadLetterReason,
758    pub attempts: u32,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub detail: Option<String>,
761    #[serde(with = "crate::encoding::bin_bytes")]
762    pub payload: Vec<u8>,
763}
764
765/// An advertised skill's health, a pinned u8 dictionary (the [`TaskState`]
766/// pattern: unknown codes pass through as [`Unrecognized`](Self::Unrecognized)).
767#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
768#[serde(from = "u8", into = "u8")]
769pub enum Health {
770    Healthy,
771    Degraded,
772    Unavailable,
773    /// A code this build does not know.
774    Unrecognized(u8),
775}
776
777impl Health {
778    /// The pinned wire code.
779    pub const fn code(self) -> u8 {
780        match self {
781            Health::Healthy => 1,
782            Health::Degraded => 2,
783            Health::Unavailable => 3,
784            Health::Unrecognized(code) => code,
785        }
786    }
787
788    /// The health for a wire code (unknown codes become
789    /// [`Unrecognized`](Self::Unrecognized)).
790    pub const fn from_code(code: u8) -> Self {
791        match code {
792            1 => Health::Healthy,
793            2 => Health::Degraded,
794            3 => Health::Unavailable,
795            other => Health::Unrecognized(other),
796        }
797    }
798}
799
800impl From<u8> for Health {
801    fn from(code: u8) -> Self {
802        Self::from_code(code)
803    }
804}
805
806impl From<Health> for u8 {
807    fn from(health: Health) -> u8 {
808        health.code()
809    }
810}
811
812/// The content shape on a capability's input or output: either a content-type
813/// or a registered writer-schema id. Externally tagged (`content_type` /
814/// `schema_id`) rather than untagged: a content-type name and a schema id are
815/// both strings, so an untagged encoding would decode a schema id literally named
816/// `json` as [`ContentType::Json`](crate::content::ContentType::Json), and a
817/// content-type name from a newer peer as a schema id. The tag makes the two
818/// unambiguous and forward-safe.
819#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
820#[serde(rename_all = "snake_case")]
821pub enum ContentRef {
822    ContentType(crate::content::ContentType),
823    SchemaId(String),
824}
825
826/// A structured capability on an [`AgentCard`]: which skill, its I/O content
827/// shape, advisory cost and latency classes, concurrency, health, and load.
828/// Mirrors the SDK A2A `AgentSkill` so the bridge maps one to the other.
829#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
830pub struct CapabilityDescriptor {
831    /// The skill identifier, capped like every vocabulary string.
832    pub skill_id: String,
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub input: Option<ContentRef>,
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub output: Option<ContentRef>,
837    /// Advisory cost class (lower is cheaper), opaque to the protocol.
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub cost_class: Option<u8>,
840    /// Advisory latency class (lower is faster), opaque to the protocol.
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub latency_class: Option<u8>,
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub max_concurrency: Option<u32>,
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub health: Option<Health>,
847    /// Current load, per-mille of advertised capacity.
848    #[serde(default, skip_serializing_if = "Option::is_none")]
849    pub load: Option<u16>,
850}
851
852/// The pinned minimal body of a liveness or capability card (`status` with
853/// `operation = card`).
854///
855/// Without a pinned shape every bridge and viewer invents its own card variant.
856/// The publishing agent rides the envelope's `source`, so the card carries only
857/// what discovery needs. Anything richer is application data in `metadata` or a
858/// follow-up body, never new card fields by convention.
859#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
860pub struct AgentCard {
861    /// Human-readable display label (a viewer shows the base32 id without
862    /// it). Capped like every vocabulary string.
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub name: Option<String>,
865    /// The agent's own version label, opaque to the protocol.
866    #[serde(default, skip_serializing_if = "Option::is_none")]
867    pub version: Option<String>,
868    /// What the agent serves: structured capability descriptors, capped in both
869    /// count and per-entry skill-id size.
870    #[serde(default, skip_serializing_if = "Vec::is_empty")]
871    pub capabilities: Vec<CapabilityDescriptor>,
872    /// How long this card stays fresh. A card older than its ttl means a dead
873    /// agent, the convention that makes cards liveness and not just discovery.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub ttl_micros: Option<u64>,
876}
877
878impl AgentCard {
879    /// Check a decoded card against the caps.
880    pub fn validate(&self) -> Result<(), ValidateError> {
881        cap_str(self.name.as_deref(), "name")?;
882        cap_str(self.version.as_deref(), "version")?;
883        if self.capabilities.len() > crate::limits::MAX_CARD_CAPABILITIES {
884            return Err(ValidateError::TooLarge {
885                field: "capabilities",
886                size: self.capabilities.len(),
887                cap: crate::limits::MAX_CARD_CAPABILITIES,
888            });
889        }
890        for capability in &self.capabilities {
891            cap_str(Some(&capability.skill_id), "capability skill_id")?;
892        }
893        Ok(())
894    }
895}
896
897/// The live presence an agent advertises in its connection metadata
898/// (`AGDX_SET_CLIENT_METADATA`), the body the discovery read surfaces per
899/// connection.
900///
901/// Distinct from [`AgentCard`] on purpose. The card is durable, replayable
902/// capability that outlives a brief disconnect (folded from the registry topic).
903/// Presence is live transport truth: it vanishes on disconnect and
904/// answers "where do I send this agent work right now," carrying the `inbox`
905/// topic the agent is currently consuming, which may be a topic it created for
906/// one workflow and drops when done. Routing resolves a capable agent to its
907/// inbox through this, never through a hard-coded shared topic name, which does
908/// not scale across users that each own their own streams and topics.
909///
910/// The body rides the opaque connection-metadata bytes with no envelope header,
911/// so it carries its own version `v` ([`PRESENCE_OP_VERSION`](crate::codes::PRESENCE_OP_VERSION)).
912/// The metadata channel stays opaque: a non-agent client advertises whatever blob
913/// its own consumers interpret, and only an AGDX agent advertises this shape.
914#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
915pub struct AgentPresence {
916    /// The presence body version, carried in-band (the metadata bytes have no
917    /// out-of-band version header).
918    pub v: u32,
919    /// Which agent this connection is, the link from a connection (keyed by
920    /// `client_id`/`user_id`) to its [`AgentCard`] (keyed by agent id). A claim
921    /// like every agent-written field, cross-checked against the verified
922    /// `user_id` the connection authenticated as.
923    pub agent: AgentId,
924    /// The topic this agent currently consumes its work on, within the stream its
925    /// connection is scoped to. Absent means liveness-only presence with no
926    /// declared inbox, so routing falls through to another signal.
927    #[serde(default, skip_serializing_if = "Option::is_none")]
928    pub inbox: Option<String>,
929}
930
931impl AgentPresence {
932    /// Presence for `agent` at the current [`PRESENCE_OP_VERSION`](crate::codes::PRESENCE_OP_VERSION),
933    /// with no declared inbox yet.
934    pub fn new(agent: AgentId) -> Self {
935        Self {
936            v: crate::codes::PRESENCE_OP_VERSION,
937            agent,
938            inbox: None,
939        }
940    }
941
942    /// Declare the `inbox` topic this agent consumes its work on.
943    pub fn with_inbox(mut self, inbox: impl Into<String>) -> Self {
944        self.inbox = Some(inbox.into());
945        self
946    }
947
948    /// Check a decoded presence body against the caps: the inbox topic is bounded
949    /// like every vocabulary string.
950    pub fn validate(&self) -> Result<(), ValidateError> {
951        cap_str(self.inbox.as_deref(), "inbox")?;
952        Ok(())
953    }
954}
955
956/// The claim-check capsule a `agdx.ct = ref` body carries.
957///
958/// The content lives elsewhere (object storage, the KV store, another topic).
959/// The record carries where it lives, how big it is, and a digest, so any
960/// consumer verifies the fetched bytes against the log without trusting the
961/// store. The envelope and the validity matrix are untouched: a referenced
962/// body is still a `body`, just one whose bytes are a `BodyRef` instead of the
963/// content itself.
964#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
965pub struct BodyRef {
966    /// Where the bytes live: a URI, object key, or KV key. Bounded by
967    /// [`MAX_BODY_REFERENCE_BYTES`](crate::limits::MAX_BODY_REFERENCE_BYTES).
968    pub reference: String,
969    /// The externalized content's size in bytes.
970    pub size_bytes: u64,
971    /// SHA-256 of the externalized content, exactly 32 bytes as a CBOR byte string.
972    #[serde(with = "crate::encoding::bin_bytes")]
973    pub sha256: Vec<u8>,
974    /// Encryption scheme code, dormant like [`Signature::scheme`]. Absent means
975    /// plaintext. Codes are assigned when the key registry (the same registry
976    /// signatures verify against) lands.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub encryption: Option<u8>,
979}
980
981const SHA256_BYTES: usize = 32;
982
983impl BodyRef {
984    /// A plaintext reference to externalized content.
985    pub fn new(reference: impl Into<String>, size_bytes: u64, sha256: [u8; 32]) -> Self {
986        Self {
987            reference: reference.into(),
988            size_bytes,
989            sha256: sha256.to_vec(),
990            encryption: None,
991        }
992    }
993
994    /// Check a decoded capsule: non-empty bounded `reference`, 32-byte digest.
995    ///
996    /// Receivers run it after decode. [`new`](Self::new) is valid by construction.
997    pub fn validate(&self) -> Result<(), ValidateError> {
998        if self.reference.is_empty() {
999            return Err(ValidateError::Invalid {
1000                field: "reference",
1001                reason: "reference must not be empty".to_owned(),
1002            });
1003        }
1004        if self.reference.len() > crate::limits::MAX_BODY_REFERENCE_BYTES {
1005            return Err(ValidateError::TooLarge {
1006                field: "reference",
1007                size: self.reference.len(),
1008                cap: crate::limits::MAX_BODY_REFERENCE_BYTES,
1009            });
1010        }
1011        if self.sha256.len() != SHA256_BYTES {
1012            return Err(ValidateError::Invalid {
1013                field: "sha256",
1014                reason: format!(
1015                    "digest must be {SHA256_BYTES} bytes, got {}",
1016                    self.sha256.len()
1017                ),
1018            });
1019        }
1020        Ok(())
1021    }
1022}
1023
1024/// A detached envelope signature: designed but dormant.
1025///
1026/// The type exists so the future opt-in (per-agent keys, consumer-side
1027/// verification against a key registry) is an additive
1028/// `signature: Option<Signature>` envelope field, not a redesign. No crypto
1029/// dependency enters this crate. Signing and verification live SDK-side.
1030///
1031/// Field sizes are scheme-discriminated, so the `scheme` byte exists precisely
1032/// to keep the type from welding to one algorithm. The wire fields are bounded
1033/// bytes, [`validate`](Self::validate) enforces the registered per-scheme
1034/// lengths, and unknown schemes pass through.
1035#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1036pub struct Signature {
1037    /// The signing scheme code ([`SIGNATURE_SCHEME_ED25519`] = 1). Codes are
1038    /// permanent, and unknown codes pass through like every open dictionary.
1039    pub scheme: u8,
1040    /// Which of the agent's registered keys signed (8 bytes for Ed25519).
1041    #[serde(with = "crate::encoding::bin_bytes")]
1042    pub key_id: Vec<u8>,
1043    /// The signature over the canonical envelope encoding (signature field
1044    /// absent), domain-separated (64 bytes for Ed25519).
1045    #[serde(with = "crate::encoding::bin_bytes")]
1046    pub bytes: Vec<u8>,
1047    /// Out-of-band interpretation attributes the signature also covers, so an
1048    /// intermediary cannot flip the codec (`agdx.ct`) or wire version (`agdx.av`)
1049    /// on a signed record without invalidating it. Folded into the signed preimage
1050    /// when present. Skip-none, so a context-less signature stays byte-identical to
1051    /// a pre-context one (an older signer, or a body whose interpretation is fixed).
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub context: Option<SignatureContext>,
1054}
1055
1056/// The interpretation-bearing attributes a [`Signature`] binds beyond the
1057/// envelope body: the content-type code (`agdx.ct`) and wire version (`agdx.av`)
1058/// a consumer reads to select its codec and decoder. Riding unsigned headers,
1059/// these would let an intermediary reinterpret a signed record. Folded into the
1060/// preimage here, a change invalidates the signature.
1061#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1062pub struct SignatureContext {
1063    /// The `agdx.ct` content-type code the body is encoded with.
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub content_type: Option<u8>,
1066    /// The `agdx.av` wire version the envelope is stamped with.
1067    #[serde(default, skip_serializing_if = "Option::is_none")]
1068    pub agent_version: Option<u32>,
1069}
1070
1071/// The Ed25519 signing scheme code.
1072pub const SIGNATURE_SCHEME_ED25519: u8 = 1;
1073
1074/// The domain separator prefixed to the canonical envelope encoding before
1075/// signing, so an AGDX signature can never be replayed into another protocol.
1076/// The canonical encoding is this crate's own: named-field CBOR, fields in
1077/// declaration order, absent optionals skipped, the signature field absent.
1078pub const SIGNATURE_DOMAIN: &[u8] = b"agdx.signature.v1";
1079
1080const ED25519_KEY_ID_BYTES: usize = 8;
1081const ED25519_SIGNATURE_BYTES: usize = 64;
1082
1083impl Signature {
1084    /// Check a signature capsule against its scheme's registered lengths.
1085    ///
1086    /// Unknown scheme codes validate, so future schemes flow through old
1087    /// consumers. Verification itself is SDK-side and scheme-aware.
1088    pub fn validate(&self) -> Result<(), ValidateError> {
1089        if self.scheme != SIGNATURE_SCHEME_ED25519 {
1090            return Ok(());
1091        }
1092        if self.key_id.len() != ED25519_KEY_ID_BYTES {
1093            return Err(ValidateError::Invalid {
1094                field: "key_id",
1095                reason: format!(
1096                    "Ed25519 key id must be {ED25519_KEY_ID_BYTES} bytes, got {}",
1097                    self.key_id.len()
1098                ),
1099            });
1100        }
1101        if self.bytes.len() != ED25519_SIGNATURE_BYTES {
1102            return Err(ValidateError::Invalid {
1103                field: "bytes",
1104                reason: format!(
1105                    "Ed25519 signature must be {ED25519_SIGNATURE_BYTES} bytes, got {}",
1106                    self.bytes.len()
1107                ),
1108            });
1109        }
1110        Ok(())
1111    }
1112}
1113
1114/// The AGDX envelope: one CBOR named-field decode unit per agent message.
1115///
1116/// Field semantics, the per-kind validity matrix, and the caps are enforced by
1117/// [`validate`], and the per-kind constructors stamp the required shape. Routing
1118/// fields (`conversation`, `target`, content type) are also stamped as typed
1119/// headers so projections and plain Iggy consumers work without decoding
1120/// bodies. The envelope is the typed, versioned form of what the headers say.
1121///
1122/// `metadata` is the AGDX-native extension slot, distinct from headers (the
1123/// substrate and observability dictionary) and `body` (the content). Foreign
1124/// metadata (A2A `metadata`, MCP `_meta`) never maps into it. It tunnels whole
1125/// inside `body`, keeping bridge round trips byte-identical.
1126#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1127pub struct AgentEnvelope {
1128    pub kind: AgentKind,
1129    /// Producer-assigned record identity, required on every kind except
1130    /// `chunk` (chunks are identified by `channel` + `sequence`, saving the
1131    /// id bytes and a clock-and-entropy call per token).
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub record: Option<RecordId>,
1134    /// Also the partition key.
1135    pub conversation: ConversationId,
1136    /// Agent-authorship claim (see the module docs).
1137    pub source: AgentId,
1138    /// Routing refinement within a shared topic. The topic itself is the
1139    /// primary address. Consumer-side filtering by `target` is a convenience,
1140    /// not a confidentiality control. The topic is the boundary.
1141    #[serde(default, skip_serializing_if = "Option::is_none")]
1142    pub target: Option<AgentId>,
1143    /// The causal parent's record id: the identity half of the causal pointer,
1144    /// stable across replication.
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    pub cause: Option<RecordId>,
1147    /// The causal parent's log position: the locator half, an O(1) dereference
1148    /// for raw log walkers, deployment-local.
1149    #[serde(default, skip_serializing_if = "Option::is_none")]
1150    pub cause_at: Option<LogPosition>,
1151    /// Request/reply pairing. A2A task identity maps onto it.
1152    #[serde(default, skip_serializing_if = "Option::is_none")]
1153    pub correlation: Option<CorrelationId>,
1154    /// Chunk grouping when many streams run under one correlation.
1155    #[serde(default, skip_serializing_if = "Option::is_none")]
1156    pub channel: Option<ChannelId>,
1157    #[serde(default, skip_serializing_if = "Option::is_none")]
1158    pub idempotency_key: Option<IdempotencyKey>,
1159    /// Drop-dead time, epoch micros. On a stream-opening message it is also
1160    /// the reader-local abandonment bound: the producer knows its own model
1161    /// timeout, the consumer would be guessing.
1162    #[serde(default, skip_serializing_if = "Option::is_none")]
1163    pub deadline_micros: Option<u64>,
1164    /// Chunk ordering within a channel.
1165    #[serde(default, skip_serializing_if = "Option::is_none")]
1166    pub sequence: Option<u64>,
1167    /// Terminal flag: the final chunk of a stream / the final task update.
1168    /// `false` is equivalent to absence and is skipped on encode, so only `true`
1169    /// has protocol meaning. Maps one-to-one onto A2A's `final`.
1170    #[serde(default, skip_serializing_if = "is_false")]
1171    pub last: bool,
1172    /// Why a stream or response ended (OTel finish-reason vocabulary: stop,
1173    /// length, content_filter, tool_call, ...). A string deliberately: that
1174    /// vocabulary belongs to OTel and the providers, not to us.
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub finish_reason: Option<String>,
1177    #[serde(default, skip_serializing_if = "Option::is_none")]
1178    pub task_state: Option<TaskState>,
1179    /// OTel `gen_ai.operation.name` value (`chat`, `execute_tool`, ...). On
1180    /// `status` it is the required discriminator (`task`, `card`, `progress`).
1181    #[serde(default, skip_serializing_if = "Option::is_none")]
1182    pub operation: Option<String>,
1183    /// OTel `gen_ai.tool.name`, for tool commands and results.
1184    #[serde(default, skip_serializing_if = "Option::is_none")]
1185    pub tool: Option<String>,
1186    #[serde(default, skip_serializing_if = "Option::is_none")]
1187    pub usage: Option<TokenUsage>,
1188    /// AGDX-native scalar extension context. Never foreign metadata.
1189    #[serde(default, skip_serializing_if = "Option::is_none")]
1190    pub metadata: Option<BTreeMap<String, Value>>,
1191    /// Must-understand marker: a bitset of feature bits a receiver MUST
1192    /// understand to process this message correctly (see [`features`]). A
1193    /// receiver that sees a set bit it does not implement MUST reject or
1194    /// dead-letter the message rather than mis-handle it ([`unmet_requirements`]).
1195    /// `0` (the default, skipped on the wire so pre-marker records stay
1196    /// byte-identical) means "ignore anything you don't understand", the
1197    /// open-world default. This lets one message demand strict handling of a new
1198    /// feature without a whole-envelope version bump (a `must_understand` marker).
1199    ///
1200    /// [`features`]: crate::agent::features
1201    /// [`unmet_requirements`]: AgentEnvelope::unmet_requirements
1202    #[serde(default, skip_serializing_if = "is_zero_u64")]
1203    pub must_understand: u64,
1204    /// The content, codec per the `agdx.ct` header. Default-empty and skipped
1205    /// when empty. The validity matrix says which kinds require it.
1206    #[serde(
1207        default,
1208        skip_serializing_if = "Vec::is_empty",
1209        with = "crate::encoding::bin_bytes"
1210    )]
1211    pub body: Vec<u8>,
1212    /// A detached signature over the canonical encoding of this envelope with the
1213    /// signature field absent, domain-separated by [`SIGNATURE_DOMAIN`]. Opt-in:
1214    /// absent (the open-world default, skipped on the wire so an unsigned record
1215    /// stays byte-identical) means an unsigned record. Verification is SDK-side
1216    /// against a per-agent key registry, so the wire crate stays crypto-free. A
1217    /// signature may ride any kind (no matrix row, like `metadata`).
1218    #[serde(default, skip_serializing_if = "Option::is_none")]
1219    pub signature: Option<Signature>,
1220}
1221
1222fn is_false(value: &bool) -> bool {
1223    !*value
1224}
1225
1226fn is_zero_u64(value: &u64) -> bool {
1227    *value == 0
1228}
1229
1230/// Must-understand feature bits for [`AgentEnvelope::must_understand`]. Each
1231/// constant names one capability a message may demand a receiver implement.
1232/// Defined additively as features land: a newer producer sets a bit an older
1233/// receiver does not know, and that receiver rejects rather than mis-handling
1234/// the message. No bits are defined yet, so today the marker is the mechanism
1235/// in place for the first feature that needs strict handling.
1236pub mod features {
1237    /// A receiver's full understood set is the OR of the bits it implements.
1238    /// With no feature bits defined yet, a current build understands none and
1239    /// only ever sees `must_understand == 0`.
1240    pub const NONE: u64 = 0;
1241}
1242
1243impl AgentEnvelope {
1244    fn base(kind: AgentKind, conversation: ConversationId, source: AgentId) -> Self {
1245        Self {
1246            kind,
1247            record: None,
1248            conversation,
1249            source,
1250            target: None,
1251            cause: None,
1252            cause_at: None,
1253            correlation: None,
1254            channel: None,
1255            idempotency_key: None,
1256            deadline_micros: None,
1257            sequence: None,
1258            last: false,
1259            finish_reason: None,
1260            task_state: None,
1261            operation: None,
1262            tool: None,
1263            usage: None,
1264            metadata: None,
1265            must_understand: 0,
1266            body: Vec::new(),
1267            signature: None,
1268        }
1269    }
1270
1271    /// Declare that a receiver MUST understand the feature `bits` to process this
1272    /// message. Bits a receiver lacks make it reject the message
1273    /// ([`unmet_requirements`](Self::unmet_requirements)). Absent bits are
1274    /// ignore-if-unknown. Additive builder method.
1275    #[must_use]
1276    pub fn requiring(mut self, bits: u64) -> Self {
1277        self.must_understand = bits;
1278        self
1279    }
1280
1281    /// The subset of this message's [`must_understand`](Self::must_understand)
1282    /// bits NOT present in the receiver's `understood` set. Non-zero means the
1283    /// receiver cannot safely process the message and must reject or
1284    /// dead-letter it rather than mis-handle a feature it does not implement.
1285    pub fn unmet_requirements(&self, understood: u64) -> u64 {
1286        self.must_understand & !understood
1287    }
1288
1289    /// A `command`: expects a reply or effect, so `correlation` is required.
1290    pub fn command(
1291        record: RecordId,
1292        conversation: ConversationId,
1293        source: AgentId,
1294        correlation: CorrelationId,
1295        body: Vec<u8>,
1296    ) -> Self {
1297        let mut envelope = Self::base(AgentKind::Command, conversation, source);
1298        envelope.record = Some(record);
1299        envelope.correlation = Some(correlation);
1300        envelope.body = body;
1301        envelope
1302    }
1303
1304    /// A `response`: the paired answer to a command (same `correlation`).
1305    pub fn response(
1306        record: RecordId,
1307        conversation: ConversationId,
1308        source: AgentId,
1309        correlation: CorrelationId,
1310        body: Vec<u8>,
1311    ) -> Self {
1312        let mut envelope = Self::base(AgentKind::Response, conversation, source);
1313        envelope.record = Some(record);
1314        envelope.correlation = Some(correlation);
1315        envelope.body = body;
1316        envelope
1317    }
1318
1319    /// An `event`: expects nothing.
1320    pub fn event(
1321        record: RecordId,
1322        conversation: ConversationId,
1323        source: AgentId,
1324        body: Vec<u8>,
1325    ) -> Self {
1326        let mut envelope = Self::base(AgentKind::Event, conversation, source);
1327        envelope.record = Some(record);
1328        envelope.body = body;
1329        envelope
1330    }
1331
1332    /// A `chunk` of the stream `channel`, ordered by `sequence`. Mark the
1333    /// final one with [`terminal`](Self::terminal).
1334    pub fn chunk(
1335        conversation: ConversationId,
1336        source: AgentId,
1337        correlation: CorrelationId,
1338        channel: ChannelId,
1339        sequence: u64,
1340        body: Vec<u8>,
1341    ) -> Self {
1342        let mut envelope = Self::base(AgentKind::Chunk, conversation, source);
1343        envelope.correlation = Some(correlation);
1344        envelope.channel = Some(channel);
1345        envelope.sequence = Some(sequence);
1346        envelope.body = body;
1347        envelope
1348    }
1349
1350    /// A `status` signal discriminated by `operation` (`task`, `card`,
1351    /// `progress`). Task updates additionally require `correlation` and
1352    /// `task_state` (use the with-setters).
1353    pub fn status(
1354        record: RecordId,
1355        conversation: ConversationId,
1356        source: AgentId,
1357        operation: impl Into<String>,
1358    ) -> Self {
1359        let mut envelope = Self::base(AgentKind::Status, conversation, source);
1360        envelope.record = Some(record);
1361        envelope.operation = Some(operation.into());
1362        envelope
1363    }
1364
1365    /// An `error` terminal for `correlation`. `body` is the encoded
1366    /// [`AgentErrorBody`].
1367    pub fn error(
1368        record: RecordId,
1369        conversation: ConversationId,
1370        source: AgentId,
1371        correlation: CorrelationId,
1372        body: Vec<u8>,
1373    ) -> Self {
1374        let mut envelope = Self::base(AgentKind::Error, conversation, source);
1375        envelope.record = Some(record);
1376        envelope.correlation = Some(correlation);
1377        envelope.body = body;
1378        envelope
1379    }
1380
1381    /// Narrow delivery to one agent within a shared topic.
1382    pub fn with_target(mut self, target: AgentId) -> Self {
1383        self.target = Some(target);
1384        self
1385    }
1386
1387    /// Stamp the causal parent: its record id (identity) and, when known, its
1388    /// log position (locator). A handler has both for free from the message it
1389    /// is replying to.
1390    pub fn with_cause(mut self, cause: RecordId, cause_at: Option<LogPosition>) -> Self {
1391        self.cause = Some(cause);
1392        self.cause_at = cause_at;
1393        self
1394    }
1395
1396    /// Pair this message with a correlation id. Required on `command`,
1397    /// `response`, `error`, and `chunk`. Optional on `event` and non-task
1398    /// `status`.
1399    pub fn with_correlation(mut self, correlation: CorrelationId) -> Self {
1400        self.correlation = Some(correlation);
1401        self
1402    }
1403
1404    /// Attach a business idempotency key (commands, responses, events only).
1405    pub fn with_idempotency_key(mut self, key: IdempotencyKey) -> Self {
1406        self.idempotency_key = Some(key);
1407        self
1408    }
1409
1410    /// Declare the drop-dead time (and, on a stream-opening message, the
1411    /// abandonment bound).
1412    pub fn with_deadline_micros(mut self, deadline_micros: u64) -> Self {
1413        self.deadline_micros = Some(deadline_micros);
1414        self
1415    }
1416
1417    /// Mark this message terminal (`last = true`), with the reason the stream
1418    /// or response ended.
1419    pub fn terminal(mut self, finish_reason: impl Into<String>) -> Self {
1420        self.last = true;
1421        self.finish_reason = Some(finish_reason.into());
1422        self
1423    }
1424
1425    /// Attach a task state. Status task updates require it. Responses and
1426    /// errors may carry it as the one-message terminal convenience.
1427    pub fn with_task_state(mut self, state: TaskState) -> Self {
1428        self.task_state = Some(state);
1429        self
1430    }
1431
1432    /// Set the OTel operation name.
1433    pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
1434        self.operation = Some(operation.into());
1435        self
1436    }
1437
1438    /// Set the OTel tool name.
1439    pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
1440        self.tool = Some(tool.into());
1441        self
1442    }
1443
1444    /// Attach token accounting (advisory).
1445    pub fn with_usage(mut self, usage: TokenUsage) -> Self {
1446        self.usage = Some(usage);
1447        self
1448    }
1449
1450    /// Add one AGDX-native metadata entry.
1451    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1452        self.metadata
1453            .get_or_insert_with(BTreeMap::new)
1454            .insert(key.into(), value.into());
1455        self
1456    }
1457
1458    /// Attach a detached [`Signature`] over this envelope. The SDK signs the
1459    /// canonical encoding with the signature absent and domain separator
1460    /// [`SIGNATURE_DOMAIN`], so the field is set last. A signature may ride any
1461    /// kind.
1462    pub fn with_signature(mut self, signature: Signature) -> Self {
1463        self.signature = Some(signature);
1464        self
1465    }
1466}
1467
1468/// A validity-matrix or cap violation. Receivers treat these as protocol
1469/// errors rather than guessing, and the SDK rejects them at publish time.
1470#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1471#[non_exhaustive]
1472pub enum ValidateError {
1473    #[error("{kind} requires `{field}`")]
1474    Missing {
1475        kind: AgentKind,
1476        field: &'static str,
1477    },
1478    #[error("`{field}` is invalid on {kind}")]
1479    Forbidden {
1480        kind: AgentKind,
1481        field: &'static str,
1482    },
1483    #[error("`{field}` is {size}B, exceeds cap {cap}B")]
1484    TooLarge {
1485        field: &'static str,
1486        size: usize,
1487        cap: usize,
1488    },
1489    #[error("`{field}`: {reason}")]
1490    Invalid { field: &'static str, reason: String },
1491}
1492
1493// The per-kind validity matrix, mechanical. R = required, O = optional,
1494// X = invalid:
1495//
1496// | field            | command | response | event | chunk | status | error |
1497// |------------------|---------|----------|-------|-------|--------|-------|
1498// | record           | R       | R        | R     | O     | R      | R     |
1499// | conversation     | R       | R        | R     | R     | R      | R     |
1500// | source           | R       | R        | R     | R     | R      | R     |
1501// | target           | O       | O        | O     | O     | O      | O     |
1502// | cause / cause_at | O       | O        | O     | O     | O      | O     |
1503// | correlation      | R       | R        | O     | R     | O (R task) | R |
1504// | channel          | X       | X        | X     | R     | X      | O     |
1505// | sequence         | X       | X        | X     | R     | X      | O (with channel) |
1506// | last             | X       | X        | X     | O     | O      | X (always terminal) |
1507// | finish_reason    | X       | O        | X     | O (with last) | X | X  |
1508// | idempotency_key  | O       | O        | O     | X     | X      | X     |
1509// | deadline_micros  | O       | X        | X     | O     | X      | X     |
1510// | task_state       | X       | O        | X     | X     | R (task) | O   |
1511// | operation        | O       | O        | O     | R seq 0 (chat|reasoning|tool_args), X after | R (task|card|progress|quarantine|unquarantine) | O |
1512// | tool             | O       | O        | O     | O     | X      | O     |
1513// | usage            | X       | O        | O     | O (terminal) | O | O  |
1514// | metadata         | O       | O        | O     | O     | O      | O     |
1515// | body             | R       | R        | R     | R (may be empty with last) | O | R |
1516/// Check an envelope against the per-kind validity matrix and the caps.
1517pub fn validate(envelope: &AgentEnvelope) -> Result<(), ValidateError> {
1518    use AgentKind::*;
1519    let kind = envelope.kind;
1520
1521    let require = |present: bool, field: &'static str| {
1522        if present {
1523            Ok(())
1524        } else {
1525            Err(ValidateError::Missing { kind, field })
1526        }
1527    };
1528    let forbid = |absent: bool, field: &'static str| {
1529        if absent {
1530            Ok(())
1531        } else {
1532            Err(ValidateError::Forbidden { kind, field })
1533        }
1534    };
1535
1536    // record: required everywhere except chunk (where it is optional).
1537    if kind != Chunk {
1538        require(envelope.record.is_some(), "record")?;
1539    }
1540
1541    // correlation.
1542    match kind {
1543        Command | Response | Chunk | Error => {
1544            require(envelope.correlation.is_some(), "correlation")?
1545        }
1546        Status => {
1547            if envelope.operation.as_deref() == Some(OPERATION_TASK) {
1548                require(envelope.correlation.is_some(), "correlation")?;
1549            }
1550        }
1551        Event => {}
1552    }
1553
1554    // channel + sequence: the chunk identity, allowed on error as a stream
1555    // terminal, sequence only alongside channel.
1556    match kind {
1557        Chunk => {
1558            require(envelope.channel.is_some(), "channel")?;
1559            require(envelope.sequence.is_some(), "sequence")?;
1560        }
1561        Error => {
1562            if envelope.sequence.is_some() && envelope.channel.is_none() {
1563                return Err(ValidateError::Invalid {
1564                    field: "sequence",
1565                    reason: "sequence requires channel".to_owned(),
1566                });
1567            }
1568        }
1569        _ => {
1570            forbid(envelope.channel.is_none(), "channel")?;
1571            forbid(envelope.sequence.is_none(), "sequence")?;
1572        }
1573    }
1574
1575    // last: chunk and status only (error is always terminal, so the flag
1576    // would be redundant noise there).
1577    if envelope.last && !matches!(kind, Chunk | Status) {
1578        return Err(ValidateError::Forbidden {
1579            kind,
1580            field: "last",
1581        });
1582    }
1583
1584    // finish_reason: responses, and terminal chunks.
1585    match kind {
1586        Response => {}
1587        Chunk => {
1588            if envelope.finish_reason.is_some() && !envelope.last {
1589                return Err(ValidateError::Invalid {
1590                    field: "finish_reason",
1591                    reason: "finish_reason rides only the terminal chunk".to_owned(),
1592                });
1593            }
1594        }
1595        _ => forbid(envelope.finish_reason.is_none(), "finish_reason")?,
1596    }
1597
1598    // idempotency_key: business idempotency for command/response/event.
1599    // Chunks and signals carry no dedup semantics.
1600    if matches!(kind, Chunk | Status | Error) {
1601        forbid(envelope.idempotency_key.is_none(), "idempotency_key")?;
1602    }
1603
1604    // deadline_micros: commands and stream-opening chunks (the abandonment
1605    // bound rides sequence 0, and a mid-stream deadline would be ambiguous).
1606    if matches!(kind, Response | Event | Status | Error) {
1607        forbid(envelope.deadline_micros.is_none(), "deadline_micros")?;
1608    }
1609    if kind == Chunk && envelope.deadline_micros.is_some() && envelope.sequence != Some(0) {
1610        return Err(ValidateError::Invalid {
1611            field: "deadline_micros",
1612            reason: "the stream bound rides the opening chunk (sequence 0)".to_owned(),
1613        });
1614    }
1615
1616    // task_state.
1617    match kind {
1618        Status => {
1619            if envelope.operation.as_deref() == Some(OPERATION_TASK) {
1620                require(envelope.task_state.is_some(), "task_state")?;
1621            }
1622        }
1623        Response | Error => {}
1624        _ => forbid(envelope.task_state.is_none(), "task_state")?,
1625    }
1626
1627    // operation: two CLOSED vocabularies (protocol machinery, version-gated
1628    // like AgentKind), open OTel values everywhere else. The status
1629    // discriminator must be task | card | progress, and the chunk-stream purpose
1630    // must be chat | reasoning | tool_args and rides ONLY the opening chunk
1631    // (sequence 0), where it is required.
1632    match kind {
1633        Status => {
1634            require(envelope.operation.is_some(), "operation")?;
1635            if let Some(operation) = envelope.operation.as_deref()
1636                && !matches!(
1637                    operation,
1638                    OPERATION_TASK
1639                        | OPERATION_CARD
1640                        | OPERATION_PROGRESS
1641                        | OPERATION_QUARANTINE
1642                        | OPERATION_UNQUARANTINE
1643                )
1644            {
1645                return Err(ValidateError::Invalid {
1646                    field: "operation",
1647                    reason: format!(
1648                        "status operation must be `{OPERATION_TASK}`, `{OPERATION_CARD}`, \
1649                         `{OPERATION_PROGRESS}`, `{OPERATION_QUARANTINE}`, or \
1650                         `{OPERATION_UNQUARANTINE}`, got `{operation}`"
1651                    ),
1652                });
1653            }
1654        }
1655        Chunk => {
1656            if envelope.sequence == Some(0) {
1657                require(envelope.operation.is_some(), "operation")?;
1658            }
1659            if let Some(operation) = envelope.operation.as_deref() {
1660                if envelope.sequence != Some(0) {
1661                    return Err(ValidateError::Invalid {
1662                        field: "operation",
1663                        reason: "the stream purpose rides the opening chunk (sequence 0)"
1664                            .to_owned(),
1665                    });
1666                }
1667                if !matches!(
1668                    operation,
1669                    OPERATION_CHAT | OPERATION_REASONING | OPERATION_TOOL_ARGS
1670                ) {
1671                    return Err(ValidateError::Invalid {
1672                        field: "operation",
1673                        reason: format!(
1674                            "chunk-stream purpose must be `{OPERATION_CHAT}`, \
1675                             `{OPERATION_REASONING}`, or `{OPERATION_TOOL_ARGS}`, \
1676                             got `{operation}`"
1677                        ),
1678                    });
1679                }
1680            }
1681        }
1682        Command | Response | Event | Error => {}
1683    }
1684
1685    // tool: meaningless on status signals.
1686    if kind == Status {
1687        forbid(envelope.tool.is_none(), "tool")?;
1688    }
1689
1690    // usage: terminal-chunk accounting, never on commands.
1691    match kind {
1692        Command => forbid(envelope.usage.is_none(), "usage")?,
1693        Chunk if envelope.usage.is_some() && !envelope.last => {
1694            return Err(ValidateError::Invalid {
1695                field: "usage",
1696                reason: "whole-stream accounting rides the terminal chunk".to_owned(),
1697            });
1698        }
1699        _ => {}
1700    }
1701
1702    // body.
1703    match kind {
1704        Status => {}
1705        Chunk => {
1706            if envelope.body.is_empty() && !envelope.last {
1707                return Err(ValidateError::Missing {
1708                    kind,
1709                    field: "body",
1710                });
1711            }
1712        }
1713        _ => require(!envelope.body.is_empty(), "body")?,
1714    }
1715
1716    // Caps. The metadata caps are the load-bearing ones: that field is
1717    // bridge-injected and foreign-influenced, so a hostile edge gets a
1718    // publish-time rejection instead of inflating every record on a topic.
1719    cap_str(envelope.operation.as_deref(), "operation")?;
1720    cap_str(envelope.tool.as_deref(), "tool")?;
1721    cap_str(envelope.finish_reason.as_deref(), "finish_reason")?;
1722    if let Some(metadata) = &envelope.metadata {
1723        if metadata.len() > crate::limits::MAX_METADATA_ENTRIES {
1724            return Err(ValidateError::TooLarge {
1725                field: "metadata",
1726                size: metadata.len(),
1727                cap: crate::limits::MAX_METADATA_ENTRIES,
1728            });
1729        }
1730        let mut total = 0usize;
1731        for (key, value) in metadata {
1732            if key.len() > crate::limits::MAX_METADATA_KEY_BYTES {
1733                return Err(ValidateError::TooLarge {
1734                    field: "metadata key",
1735                    size: key.len(),
1736                    cap: crate::limits::MAX_METADATA_KEY_BYTES,
1737                });
1738            }
1739            let value_size = value_size(value);
1740            if value_size > crate::limits::MAX_METADATA_VALUE_BYTES {
1741                return Err(ValidateError::TooLarge {
1742                    field: "metadata value",
1743                    size: value_size,
1744                    cap: crate::limits::MAX_METADATA_VALUE_BYTES,
1745                });
1746            }
1747            total += key.len() + value_size;
1748        }
1749        if total > crate::limits::MAX_METADATA_TOTAL_BYTES {
1750            return Err(ValidateError::TooLarge {
1751                field: "metadata",
1752                size: total,
1753                cap: crate::limits::MAX_METADATA_TOTAL_BYTES,
1754            });
1755        }
1756    }
1757    // A signature may ride any kind (no matrix row, like metadata). When present
1758    // its capsule must hold its scheme's registered lengths.
1759    if let Some(signature) = &envelope.signature {
1760        signature.validate()?;
1761    }
1762    Ok(())
1763}
1764
1765/// The `status` operation value for task lifecycle updates.
1766pub const OPERATION_TASK: &str = "task";
1767/// The `status` operation value for liveness/capability cards.
1768pub const OPERATION_CARD: &str = "card";
1769/// The `status` operation value for progress ticks.
1770pub const OPERATION_PROGRESS: &str = "progress";
1771/// The `status` operation value for a quarantine fact: an operator marks an agent
1772/// out of routing. The body is the quarantined agent id. Authorized by the
1773/// registry topic's write access control (only an operator may append it), and
1774/// optionally signed for defense in depth.
1775pub const OPERATION_QUARANTINE: &str = "quarantine";
1776/// The `status` operation value for an un-quarantine fact: an operator lifts a
1777/// prior quarantine, returning the agent to routing. The body is the agent id.
1778/// Same authorization as [`OPERATION_QUARANTINE`], so quarantine is not a
1779/// one-way door that only retention expiry can undo.
1780pub const OPERATION_UNQUARANTINE: &str = "unquarantine";
1781
1782// The chunk-stream purpose vocabulary: `operation` on the stream-opening
1783// chunk says what the channel IS, so a consumer reassembling several channels
1784// under one correlation (answer text next to reasoning next to streamed tool
1785// arguments) tells them apart without decoding bodies. Pinned here because
1786// every bridge and viewer would otherwise invent its own spelling.
1787/// The chunk-stream `operation` value for answer/content text (OTel's `chat`).
1788pub const OPERATION_CHAT: &str = "chat";
1789/// The chunk-stream `operation` value for a model's reasoning stream.
1790pub const OPERATION_REASONING: &str = "reasoning";
1791/// The chunk-stream `operation` value for streamed tool-call arguments.
1792pub const OPERATION_TOOL_ARGS: &str = "tool_args";
1793
1794// The state-sync convention: UI/shared state rides `event` envelopes
1795// discriminated by `operation`, never a new kind. Replaying snapshot + deltas
1796// reconstructs the state at any historical offset.
1797/// The `event` operation value for a full state snapshot (the body is the
1798/// state, codec per `agdx.ct`).
1799pub const OPERATION_STATE_SNAPSHOT: &str = "state_snapshot";
1800/// The `event` operation value for a state delta (the body is an RFC 6902
1801/// JSON Patch document).
1802pub const OPERATION_STATE_DELTA: &str = "state_delta";
1803
1804// Pinned AGDX-native metadata keys. Values stay strings or scalars per the
1805// metadata rules. The keys are pinned so transcripts, bridges, and projections
1806// agree without per-app conventions.
1807/// Metadata key: the message's chat role. Recommended values: `user`,
1808/// `assistant`, `system`, `tool`. A string because that vocabulary belongs to
1809/// the model providers and the edge protocols, not to us.
1810pub const METADATA_ROLE: &str = "role";
1811/// Metadata key: the bridge hop list, a `Value::List` of bridge id strings.
1812/// A bridge republishing a message appends its own id, and drops a message
1813/// whose hop list already contains it: the loop guard for multi-bridge
1814/// deployments (A2A in, AG-UI out, A2A out again). Bounded by the metadata
1815/// caps like every other entry.
1816pub const METADATA_BRIDGE_HOPS: &str = "bridge_hops";
1817/// Metadata key: the run id a status record belongs to, stamped by a
1818/// registered workflow or contract and read by the run-registry fold. A record
1819/// without it never enters the fold, so the key costs nothing and means
1820/// nothing for everything that is not a registered run. Bounded by the
1821/// metadata caps like every other entry.
1822pub const METADATA_RUN: &str = "run";
1823
1824/// Metadata key: the user an agent acts on behalf of. It rides `metadata` (not a
1825/// header) so it falls inside the signed envelope span, so the signer cannot
1826/// forge whom it claims to act for.
1827pub const METADATA_DELEGATED_BY: &str = "on_behalf_of";
1828/// Metadata key: the declared purpose of the operation, a stable input for a
1829/// policy engine at the effect boundary. Advisory unless the envelope is signed.
1830pub const METADATA_PURPOSE: &str = "purpose";
1831/// Metadata key: the declared classification of the data the operation touches.
1832/// Advisory unless the envelope is signed.
1833pub const METADATA_DATA_CLASSIFICATION: &str = "data_classification";
1834/// Metadata key: the task this operation serves. Advisory unless the envelope
1835/// is signed.
1836pub const METADATA_TASK_CONTEXT: &str = "task_context";
1837/// Metadata key: the session's declared intent. Advisory unless the envelope
1838/// is signed.
1839pub const METADATA_SESSION_INTENT: &str = "session_intent";
1840
1841// Crockford base32, the canonical display form of every u128 id (26
1842// characters, the same rendering ULIDs use). Hand-rolled so the crate stays
1843// dependency-free. Generation (entropy and clock) lives SDK-side.
1844const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1845
1846pub(crate) fn crockford_encode(value: u128) -> [u8; 26] {
1847    let mut out = [0u8; 26];
1848    let mut v = value;
1849    for slot in out.iter_mut().rev() {
1850        *slot = CROCKFORD[(v & 0x1f) as usize];
1851        v >>= 5;
1852    }
1853    out
1854}
1855
1856pub(crate) fn crockford_decode(s: &str) -> Result<u128, IdParseError> {
1857    let bytes = s.as_bytes();
1858    if bytes.len() != 26 {
1859        return Err(IdParseError::Length { got: bytes.len() });
1860    }
1861    let mut value: u128 = 0;
1862    for (i, byte) in bytes.iter().enumerate() {
1863        let digit = CROCKFORD
1864            .iter()
1865            .position(|c| *c == byte.to_ascii_uppercase())
1866            .ok_or(IdParseError::Char(*byte as char))?;
1867        // 26 chars carry 130 bits. The top character may only use 3 of its 5
1868        // (the ULID overflow rule), so a first digit past 7 cannot fit u128.
1869        if i == 0 && digit > 7 {
1870            return Err(IdParseError::Overflow);
1871        }
1872        value = (value << 5) | digit as u128;
1873    }
1874    Ok(value)
1875}
1876
1877fn cap_str(value: Option<&str>, field: &'static str) -> Result<(), ValidateError> {
1878    if let Some(value) = value
1879        && value.len() > crate::limits::MAX_AGENT_STRING_BYTES
1880    {
1881        return Err(ValidateError::TooLarge {
1882            field,
1883            size: value.len(),
1884            cap: crate::limits::MAX_AGENT_STRING_BYTES,
1885        });
1886    }
1887    Ok(())
1888}
1889
1890// Approximate scalar size in bytes for the metadata caps: text by length,
1891// scalars by their widest encoding, lists by the sum of their elements.
1892fn value_size(value: &Value) -> usize {
1893    match value {
1894        Value::Str(s) => s.len(),
1895        Value::List(items) => items.iter().map(|item| 1 + value_size(item)).sum(),
1896        _ => 9,
1897    }
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902    use super::*;
1903
1904    #[test]
1905    fn given_an_id_when_displayed_then_should_round_trip_through_crockford_base32() {
1906        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
1907        let text = id.to_string();
1908        assert_eq!(text.len(), 26);
1909        assert_eq!(text.parse::<RecordId>().expect("parses"), id);
1910        // Lowercase parses too (Crockford is case-insensitive).
1911        assert_eq!(text.to_lowercase().parse::<RecordId>().expect("parses"), id);
1912        assert_eq!(
1913            RecordId::from_u128(0).to_string(),
1914            "00000000000000000000000000"
1915        );
1916        assert_eq!(
1917            RecordId::from_u128(u128::MAX).to_string(),
1918            "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"
1919        );
1920    }
1921
1922    #[test]
1923    fn given_an_id_in_json_when_encoded_then_should_use_readable_text() {
1924        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
1925        let json = serde_json::to_string(&id).expect("serializes");
1926        assert_eq!(json, format!("\"{id}\""));
1927        assert_eq!(
1928            serde_json::from_str::<RecordId>(&json).expect("deserializes"),
1929            id
1930        );
1931        let byte_array = serde_json::to_string(&[0_u8; 16]).expect("serializes byte array");
1932        assert!(serde_json::from_str::<RecordId>(&byte_array).is_err());
1933    }
1934
1935    #[cfg(feature = "cbor")]
1936    #[test]
1937    fn given_an_id_in_named_cbor_when_encoded_then_should_use_fixed_width_bytes() {
1938        let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
1939        let encoded = crate::framing::encode_named(&id).expect("serializes");
1940        let decoded = crate::framing::decode_named::<RecordId>(&encoded).expect("deserializes");
1941        assert_eq!(decoded, id);
1942    }
1943
1944    #[test]
1945    fn given_invalid_id_strings_when_parsed_then_should_reject_with_the_right_error() {
1946        assert_eq!(
1947            "short".parse::<RecordId>(),
1948            Err(IdParseError::Length { got: 5 })
1949        );
1950        assert_eq!(
1951            "8ZZZZZZZZZZZZZZZZZZZZZZZZZ".parse::<RecordId>(),
1952            Err(IdParseError::Overflow)
1953        );
1954        assert!(matches!(
1955            "UUUUUUUUUUUUUUUUUUUUUUUUUU".parse::<RecordId>(),
1956            Err(IdParseError::Char('U'))
1957        ));
1958    }
1959
1960    #[test]
1961    fn given_agent_id_strings_when_parsed_then_should_accept_printable_and_reject_control() {
1962        for s in ["planner", "planner@acme.example", "team/planner", "a:b"] {
1963            assert_eq!(
1964                s.parse::<AgentId>()
1965                    .expect("a printable agent id is valid")
1966                    .as_str(),
1967                s
1968            );
1969        }
1970        assert!("".parse::<AgentId>().is_err());
1971        assert!("bad\nid".parse::<AgentId>().is_err());
1972    }
1973
1974    #[test]
1975    fn given_task_state_codes_when_mapped_then_should_match_the_pinned_dictionary() {
1976        let expected = [
1977            (TaskState::Submitted, 1u8),
1978            (TaskState::Working, 2),
1979            (TaskState::InputRequired, 3),
1980            (TaskState::Completed, 4),
1981            (TaskState::Canceled, 5),
1982            (TaskState::Failed, 6),
1983            (TaskState::Rejected, 7),
1984            (TaskState::AuthRequired, 8),
1985            (TaskState::Unknown, 9),
1986        ];
1987        for (state, code) in expected {
1988            assert_eq!(state.code(), code);
1989            assert_eq!(TaskState::from_code(code), state);
1990        }
1991        // An unknown code decodes and passes through as non-terminal.
1992        let future = TaskState::from_code(42);
1993        assert_eq!(future, TaskState::Unrecognized(42));
1994        assert_eq!(future.code(), 42);
1995        assert!(!future.is_terminal());
1996        assert!(TaskState::Completed.is_terminal());
1997        assert!(!TaskState::Working.is_terminal());
1998    }
1999
2000    #[test]
2001    fn given_task_state_names_when_round_tripped_then_should_match_the_a2a_vocabulary() {
2002        assert_eq!(TaskState::InputRequired.to_string(), "input-required");
2003        assert_eq!(
2004            "auth-required".parse::<TaskState>().expect("parses"),
2005            TaskState::AuthRequired
2006        );
2007        assert!("nope".parse::<TaskState>().is_err());
2008    }
2009
2010    #[test]
2011    fn given_error_and_dead_letter_codes_when_mapped_then_should_match_the_dictionaries() {
2012        assert_eq!(AgentErrorCode::InvalidRequest.code(), 1);
2013        assert_eq!(AgentErrorCode::Internal.code(), 7);
2014        assert_eq!(
2015            AgentErrorCode::from_code(99),
2016            AgentErrorCode::Unrecognized(99)
2017        );
2018        assert_eq!(DeadLetterReason::RetryExhausted.code(), 1);
2019        assert_eq!(DeadLetterReason::DeadlineExceeded.code(), 4);
2020        assert_eq!(
2021            DeadLetterReason::from_code(77),
2022            DeadLetterReason::Unrecognized(77)
2023        );
2024    }
2025
2026    #[test]
2027    fn given_every_u8_when_mapped_through_the_dictionaries_then_the_code_should_round_trip() {
2028        // Totality: from_code is defined on the whole u8 space (unknown codes
2029        // become Unrecognized), and code() is its left inverse, so no byte can
2030        // panic or be lost on decode.
2031        for code in 0u8..=u8::MAX {
2032            assert_eq!(TaskState::from_code(code).code(), code);
2033            assert_eq!(AgentErrorCode::from_code(code).code(), code);
2034            assert_eq!(DeadLetterReason::from_code(code).code(), code);
2035        }
2036    }
2037
2038    #[test]
2039    fn given_an_idempotency_key_when_validated_then_should_enforce_the_cap() {
2040        assert!("job-123-attempt-2".parse::<IdempotencyKey>().is_ok());
2041        assert!("".parse::<IdempotencyKey>().is_err());
2042        assert!("x".repeat(65).parse::<IdempotencyKey>().is_err());
2043    }
2044
2045    #[test]
2046    fn given_a_command_when_validated_then_should_pass_and_enforce_the_matrix() {
2047        let (record, conversation, source, correlation) = ids();
2048        let command =
2049            AgentEnvelope::command(record, conversation, source, correlation, b"do".to_vec());
2050        validate(&command).expect("a well-formed command validates");
2051
2052        // A command without correlation is an event wearing the wrong kind.
2053        let mut missing = command.clone();
2054        missing.correlation = None;
2055        assert_eq!(
2056            validate(&missing),
2057            Err(ValidateError::Missing {
2058                kind: AgentKind::Command,
2059                field: "correlation"
2060            })
2061        );
2062
2063        // usage is X on commands (accounting rides replies and terminals).
2064        let with_usage = command.clone().with_usage(TokenUsage::default());
2065        assert!(matches!(
2066            validate(&with_usage),
2067            Err(ValidateError::Forbidden { field: "usage", .. })
2068        ));
2069
2070        // channel is chunk identity, invalid elsewhere.
2071        let mut with_channel = command;
2072        with_channel.channel = Some(ChannelId::from_u128(1));
2073        assert!(matches!(
2074            validate(&with_channel),
2075            Err(ValidateError::Forbidden {
2076                field: "channel",
2077                ..
2078            })
2079        ));
2080    }
2081
2082    #[test]
2083    fn given_chunks_when_validated_then_should_enforce_stream_semantics() {
2084        let (_, conversation, source, correlation) = ids();
2085        let channel = ChannelId::from_u128(23);
2086        let chunk = AgentEnvelope::chunk(
2087            conversation,
2088            source.clone(),
2089            correlation,
2090            channel,
2091            0,
2092            b"tok".to_vec(),
2093        )
2094        .with_operation(OPERATION_CHAT);
2095        validate(&chunk).expect("a stream chunk validates");
2096
2097        // The opening chunk REQUIRES its purpose, from the pinned vocabulary.
2098        let mut undeclared = chunk.clone();
2099        undeclared.operation = None;
2100        assert!(matches!(
2101            validate(&undeclared),
2102            Err(ValidateError::Missing {
2103                field: "operation",
2104                ..
2105            })
2106        ));
2107        let mut off_vocabulary = chunk.clone();
2108        off_vocabulary.operation = Some("telemetry".to_owned());
2109        assert!(matches!(
2110            validate(&off_vocabulary),
2111            Err(ValidateError::Invalid {
2112                field: "operation",
2113                ..
2114            })
2115        ));
2116
2117        // The purpose rides ONLY the opening chunk.
2118        let redeclared = AgentEnvelope::chunk(
2119            conversation,
2120            source.clone(),
2121            correlation,
2122            channel,
2123            3,
2124            b"tok".to_vec(),
2125        )
2126        .with_operation(OPERATION_REASONING);
2127        assert!(matches!(
2128            validate(&redeclared),
2129            Err(ValidateError::Invalid {
2130                field: "operation",
2131                ..
2132            })
2133        ));
2134
2135        // The terminal chunk may be empty and carries finish_reason + usage.
2136        let terminal = AgentEnvelope::chunk(
2137            conversation,
2138            source.clone(),
2139            correlation,
2140            channel,
2141            41,
2142            Vec::new(),
2143        )
2144        .terminal("stop")
2145        .with_usage(TokenUsage {
2146            input_tokens: 100,
2147            output_tokens: 42,
2148            ..Default::default()
2149        });
2150        validate(&terminal).expect("a terminal chunk validates");
2151
2152        // A non-terminal chunk cannot carry finish_reason or usage.
2153        let mut early_finish = chunk.clone();
2154        early_finish.finish_reason = Some("stop".to_owned());
2155        assert!(matches!(
2156            validate(&early_finish),
2157            Err(ValidateError::Invalid {
2158                field: "finish_reason",
2159                ..
2160            })
2161        ));
2162
2163        // An empty non-terminal chunk carries nothing.
2164        let empty = AgentEnvelope::chunk(
2165            conversation,
2166            source.clone(),
2167            correlation,
2168            channel,
2169            1,
2170            Vec::new(),
2171        );
2172        assert!(matches!(
2173            validate(&empty),
2174            Err(ValidateError::Missing { field: "body", .. })
2175        ));
2176
2177        // Chunks carry no dedup semantics.
2178        let mut keyed = chunk.clone();
2179        keyed.idempotency_key = Some("k".parse().expect("valid key"));
2180        assert!(matches!(
2181            validate(&keyed),
2182            Err(ValidateError::Forbidden {
2183                field: "idempotency_key",
2184                ..
2185            })
2186        ));
2187
2188        // The abandonment bound rides only the opening chunk (sequence 0).
2189        let opening = chunk.with_deadline_micros(1);
2190        validate(&opening).expect("an opening chunk may declare the bound");
2191        let late = AgentEnvelope::chunk(
2192            conversation,
2193            source.clone(),
2194            correlation,
2195            channel,
2196            5,
2197            b"tok".to_vec(),
2198        )
2199        .with_deadline_micros(1);
2200        assert!(matches!(
2201            validate(&late),
2202            Err(ValidateError::Invalid {
2203                field: "deadline_micros",
2204                ..
2205            })
2206        ));
2207    }
2208
2209    #[test]
2210    fn given_status_signals_when_validated_then_task_updates_should_require_state() {
2211        let (record, conversation, source, correlation) = ids();
2212        // A liveness card: no correlation, no task_state.
2213        let card = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_CARD);
2214        validate(&card).expect("a card validates");
2215
2216        // A task update requires correlation + task_state.
2217        let bare_task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK);
2218        assert!(matches!(
2219            validate(&bare_task),
2220            Err(ValidateError::Missing {
2221                field: "correlation",
2222                ..
2223            })
2224        ));
2225        let task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK)
2226            .with_correlation(correlation)
2227            .with_task_state(TaskState::Working);
2228        validate(&task).expect("a task update validates");
2229
2230        // The discriminator is a CLOSED vocabulary: task | card | progress.
2231        let off_vocabulary =
2232            AgentEnvelope::status(record, conversation, source.clone(), "telemetry");
2233        assert!(matches!(
2234            validate(&off_vocabulary),
2235            Err(ValidateError::Invalid {
2236                field: "operation",
2237                ..
2238            })
2239        ));
2240    }
2241
2242    #[test]
2243    fn given_an_error_when_validated_then_last_should_be_forbidden() {
2244        let (record, conversation, source, correlation) = ids();
2245        let error =
2246            AgentEnvelope::error(record, conversation, source, correlation, b"boom".to_vec());
2247        validate(&error).expect("an error validates");
2248
2249        // error is ALWAYS terminal, so the flag would be redundant.
2250        let mut flagged = error.clone();
2251        flagged.last = true;
2252        assert!(matches!(
2253            validate(&flagged),
2254            Err(ValidateError::Forbidden { field: "last", .. })
2255        ));
2256
2257        // sequence without channel is incoherent.
2258        let mut dangling = error;
2259        dangling.sequence = Some(3);
2260        assert!(matches!(
2261            validate(&dangling),
2262            Err(ValidateError::Invalid {
2263                field: "sequence",
2264                ..
2265            })
2266        ));
2267    }
2268
2269    fn descriptor(skill_id: &str) -> CapabilityDescriptor {
2270        CapabilityDescriptor {
2271            skill_id: skill_id.to_owned(),
2272            input: None,
2273            output: None,
2274            cost_class: None,
2275            latency_class: None,
2276            max_concurrency: None,
2277            health: None,
2278            load: None,
2279        }
2280    }
2281
2282    #[test]
2283    fn given_an_agent_card_when_validated_then_should_enforce_the_caps() {
2284        let card = AgentCard {
2285            name: Some("rollout-planner".to_owned()),
2286            version: Some("1.4.2".to_owned()),
2287            capabilities: vec![
2288                CapabilityDescriptor {
2289                    skill_id: "chat".to_owned(),
2290                    input: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2291                    output: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2292                    cost_class: Some(2),
2293                    latency_class: Some(1),
2294                    max_concurrency: Some(8),
2295                    health: Some(Health::Healthy),
2296                    load: Some(250),
2297                },
2298                descriptor("plan_rollout"),
2299            ],
2300            ttl_micros: Some(30_000_000),
2301        };
2302        card.validate().expect("a well-formed card validates");
2303
2304        let mut crowded = card.clone();
2305        crowded.capabilities = vec![descriptor("x"); crate::limits::MAX_CARD_CAPABILITIES + 1];
2306        assert!(matches!(
2307            crowded.validate(),
2308            Err(ValidateError::TooLarge {
2309                field: "capabilities",
2310                ..
2311            })
2312        ));
2313
2314        let mut oversized = card;
2315        oversized.name = Some("n".repeat(crate::limits::MAX_AGENT_STRING_BYTES + 1));
2316        assert!(matches!(
2317            oversized.validate(),
2318            Err(ValidateError::TooLarge { field: "name", .. })
2319        ));
2320    }
2321
2322    #[cfg(feature = "cbor")]
2323    #[test]
2324    fn given_a_schema_id_named_like_a_content_type_when_round_tripped_then_should_stay_a_schema_id()
2325    {
2326        use crate::framing::{decode_named, encode_named};
2327        // The tag disambiguates a schema id that happens to be a content-type
2328        // name: an untagged encoding would decode this as ContentType::Json.
2329        let reference = ContentRef::SchemaId("json".to_owned());
2330        let bytes = encode_named(&reference).expect("encodes");
2331        let back: ContentRef = decode_named(&bytes).expect("decodes");
2332        assert_eq!(back, ContentRef::SchemaId("json".to_owned()));
2333        // A genuine content-type still round-trips as one.
2334        let ct = ContentRef::ContentType(crate::content::ContentType::Json);
2335        let back: ContentRef = decode_named(&encode_named(&ct).expect("encodes")).expect("decodes");
2336        assert_eq!(back, ct);
2337    }
2338
2339    #[test]
2340    fn given_a_signature_when_validated_then_should_enforce_per_scheme_lengths() {
2341        let valid = Signature {
2342            scheme: SIGNATURE_SCHEME_ED25519,
2343            key_id: vec![1u8; 8],
2344            bytes: vec![2u8; 64],
2345            context: None,
2346        };
2347        valid
2348            .validate()
2349            .expect("a well-formed Ed25519 signature validates");
2350
2351        let mut short_key = valid.clone();
2352        short_key.key_id = vec![1u8; 4];
2353        assert!(matches!(
2354            short_key.validate(),
2355            Err(ValidateError::Invalid {
2356                field: "key_id",
2357                ..
2358            })
2359        ));
2360
2361        let mut short_signature = valid.clone();
2362        short_signature.bytes = vec![2u8; 32];
2363        assert!(matches!(
2364            short_signature.validate(),
2365            Err(ValidateError::Invalid { field: "bytes", .. })
2366        ));
2367
2368        // Unknown scheme codes pass through (the open-dictionary rule).
2369        // Verification is SDK-side and scheme-aware.
2370        let future = Signature {
2371            scheme: 42,
2372            key_id: vec![1u8; 3],
2373            bytes: vec![2u8; 99],
2374            context: None,
2375        };
2376        future.validate().expect("an unknown scheme flows through");
2377    }
2378
2379    #[test]
2380    fn given_a_body_ref_when_validated_then_should_enforce_reference_and_digest() {
2381        let valid = BodyRef::new("s3://transcripts/conv-1/msg-9", 4_194_304, [7u8; 32]);
2382        valid.validate().expect("a well-formed reference validates");
2383
2384        let mut empty = valid.clone();
2385        empty.reference = String::new();
2386        assert!(matches!(
2387            empty.validate(),
2388            Err(ValidateError::Invalid {
2389                field: "reference",
2390                ..
2391            })
2392        ));
2393
2394        let mut oversized = valid.clone();
2395        oversized.reference = "x".repeat(crate::limits::MAX_BODY_REFERENCE_BYTES + 1);
2396        assert!(matches!(
2397            oversized.validate(),
2398            Err(ValidateError::TooLarge {
2399                field: "reference",
2400                ..
2401            })
2402        ));
2403
2404        let mut truncated = valid;
2405        truncated.sha256 = vec![7u8; 16];
2406        assert!(matches!(
2407            truncated.validate(),
2408            Err(ValidateError::Invalid {
2409                field: "sha256",
2410                ..
2411            })
2412        ));
2413    }
2414
2415    #[test]
2416    fn given_oversized_metadata_when_validated_then_should_reject() {
2417        let (record, conversation, source, correlation) = ids();
2418        let mut command =
2419            AgentEnvelope::command(record, conversation, source, correlation, b"x".to_vec());
2420        for i in 0..crate::limits::MAX_METADATA_ENTRIES + 1 {
2421            command = command.with_metadata(format!("k{i}"), i as i64);
2422        }
2423        assert!(matches!(
2424            validate(&command),
2425            Err(ValidateError::TooLarge {
2426                field: "metadata",
2427                ..
2428            })
2429        ));
2430    }
2431
2432    fn ids() -> (RecordId, ConversationId, AgentId, CorrelationId) {
2433        (
2434            RecordId::from_u128(7),
2435            ConversationId::from_u128(11),
2436            "test-agent".parse().expect("valid agent id"),
2437            CorrelationId::from_u128(17),
2438        )
2439    }
2440}
2441
2442#[cfg(all(test, feature = "cbor"))]
2443mod wire_tests {
2444    use super::*;
2445    use crate::framing::{decode_named, encode_named};
2446
2447    #[test]
2448    fn given_a_wrong_length_locator_when_decoded_then_should_error_not_panic() {
2449        // CBOR byte string: 0x40 | len for lengths below 24. Exactly 20 bytes
2450        // is the only valid locator. Anything else is a clean decode error.
2451        let mut valid = vec![0x40 | 20];
2452        valid.extend_from_slice(&[0u8; 20]);
2453        decode_named::<LogPosition>(&valid).expect("20 packed bytes decode");
2454
2455        for bad_len in [0u8, 19, 21, 23] {
2456            let mut bytes = vec![0x40 | bad_len];
2457            bytes.extend_from_slice(&vec![0u8; bad_len as usize]);
2458            assert!(
2459                decode_named::<LogPosition>(&bytes).is_err(),
2460                "a {bad_len}-byte locator must error, not panic"
2461            );
2462        }
2463    }
2464
2465    #[test]
2466    fn given_a_locator_when_round_tripped_then_should_preserve_every_field() {
2467        let pos = LogPosition::new(0x0102_0304, 0x0506_0708, 0x090A_0B0C, 0x0D0E_0F10_1112_1314);
2468        let bytes = encode_named(&pos).expect("encodes");
2469        let back: LogPosition = decode_named(&bytes).expect("decodes");
2470        assert_eq!(back, pos);
2471    }
2472
2473    #[test]
2474    fn given_an_envelope_when_round_tripped_then_should_preserve_every_field() {
2475        let envelope = AgentEnvelope::command(
2476            RecordId::from_u128(1),
2477            ConversationId::from_u128(2),
2478            "source-agent".parse().expect("valid agent id"),
2479            CorrelationId::from_u128(4),
2480            b"payload".to_vec(),
2481        )
2482        .with_target("target-agent".parse().expect("valid agent id"))
2483        .with_cause(RecordId::from_u128(6), Some(LogPosition::new(1, 2, 3, 44)))
2484        .with_idempotency_key("job-1".parse().expect("valid key"))
2485        .with_deadline_micros(1_700_000_000_000_000)
2486        .with_operation("chat")
2487        .with_tool("search")
2488        .with_metadata("pool", "eu-west");
2489        let bytes = encode_named(&envelope).expect("encodes");
2490        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
2491        assert_eq!(back, envelope);
2492    }
2493
2494    #[test]
2495    fn given_a_must_understand_marker_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
2496        // A receiver lacking a required bit must see it as unmet, and the
2497        // default-zero marker is omitted on the wire so pre-marker records stay
2498        // byte-identical.
2499        let envelope = AgentEnvelope::event(
2500            RecordId::from_u128(1),
2501            ConversationId::from_u128(2),
2502            "source-agent".parse().expect("valid agent id"),
2503            b"e".to_vec(),
2504        )
2505        .requiring(0b101);
2506        let bytes = encode_named(&envelope).expect("encodes");
2507        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
2508        assert_eq!(back.must_understand, 0b101);
2509        // A receiver that understands bit 0 but not bit 2 has bit 2 unmet.
2510        assert_eq!(back.unmet_requirements(0b001), 0b100);
2511        // A receiver that understands both has nothing unmet.
2512        assert_eq!(back.unmet_requirements(0b111), 0);
2513        // The zero marker (open-world default) is unmet by nobody and omitted.
2514        let plain = AgentEnvelope::event(
2515            RecordId::from_u128(1),
2516            ConversationId::from_u128(2),
2517            "source-agent".parse().expect("valid agent id"),
2518            b"e".to_vec(),
2519        );
2520        assert_eq!(plain.unmet_requirements(features::NONE), 0);
2521        let json = serde_json::to_string(&plain).expect("json");
2522        assert!(
2523            !json.contains("must_understand"),
2524            "zero marker must be omitted: {json}"
2525        );
2526    }
2527
2528    #[test]
2529    fn given_absent_options_when_encoded_then_should_cost_zero_bytes() {
2530        // The skip-serializing discipline: a minimal event encodes only its
2531        // five present fields (kind, record, conversation, source, body).
2532        let envelope = AgentEnvelope::event(
2533            RecordId::from_u128(1),
2534            ConversationId::from_u128(2),
2535            "source-agent".parse().expect("valid agent id"),
2536            b"e".to_vec(),
2537        );
2538        let bytes = encode_named(&envelope).expect("encodes");
2539        // A CBOR map with at most 15 fields encodes its count in the head byte's
2540        // low nibble (major type 5, `0xa0 | count`).
2541        assert_eq!(bytes[0] & 0x0f, 5, "absent optionals must not be encoded");
2542        let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
2543        assert!(!back.last);
2544        assert!(back.metadata.is_none());
2545    }
2546
2547    #[test]
2548    fn given_an_id_when_encoded_then_should_ride_as_one_fixed_width_byte_string() {
2549        let bytes = encode_named(&RecordId::from_u128(0x0102)).expect("encodes");
2550        // CBOR byte-string head `0x50` (major type 2, length 16), then the 16
2551        // big-endian bytes, fixed width, no bignum tag. 17 bytes total.
2552        assert_eq!(bytes.len(), 17);
2553        assert_eq!(bytes[0], 0x50);
2554        assert_eq!(bytes[16], 0x02);
2555        assert_eq!(bytes[15], 0x01);
2556    }
2557
2558    #[test]
2559    fn given_a_task_state_when_encoded_then_should_ride_as_a_bare_u8() {
2560        let bytes = encode_named(&TaskState::Completed).expect("encodes");
2561        assert_eq!(bytes, vec![4]);
2562        let unknown = encode_named(&42u8).expect("encodes a raw code");
2563        let back: TaskState = decode_named(&unknown).expect("unknown code decodes");
2564        assert_eq!(back, TaskState::Unrecognized(42));
2565    }
2566
2567    #[test]
2568    fn given_an_error_body_when_round_tripped_then_should_preserve_the_dictionary_code() {
2569        let body = AgentErrorBody {
2570            code: AgentErrorCode::ToolFailure,
2571            message: Some("search timed out".to_owned()),
2572            retryable: true,
2573            detail: Some(BTreeMap::from([("attempt".to_owned(), Value::Int(3))])),
2574        };
2575        let bytes = encode_named(&body).expect("encodes");
2576        let back: AgentErrorBody = decode_named(&bytes).expect("decodes");
2577        assert_eq!(back, body);
2578    }
2579
2580    #[test]
2581    fn given_a_body_ref_when_round_tripped_then_should_preserve_the_digest_as_a_byte_string() {
2582        let capsule = BodyRef::new("kv://bodies/abc", 1024, [9u8; 32]);
2583        let bytes = encode_named(&capsule).expect("encodes");
2584        let back: BodyRef = decode_named(&bytes).expect("decodes");
2585        assert_eq!(back, capsule);
2586        assert!(back.encryption.is_none(), "absent encryption costs nothing");
2587        back.validate().expect("decoded capsule validates");
2588    }
2589
2590    #[test]
2591    fn given_a_dead_letter_when_round_tripped_then_payload_should_stay_byte_identical() {
2592        let inner = AgentEnvelope::command(
2593            RecordId::from_u128(9),
2594            ConversationId::from_u128(8),
2595            "source-agent".parse().expect("valid agent id"),
2596            CorrelationId::from_u128(6),
2597            b"poison".to_vec(),
2598        );
2599        let payload = encode_named(&inner).expect("inner encodes");
2600        let capsule = AgentDeadLetter {
2601            source: LogPosition::new(1, 2, 3, 99),
2602            reason: DeadLetterReason::RetryExhausted,
2603            attempts: 5,
2604            detail: Some("handler kept failing".to_owned()),
2605            payload: payload.clone(),
2606        };
2607        let bytes = encode_named(&capsule).expect("encodes");
2608        let back: AgentDeadLetter = decode_named(&bytes).expect("decodes");
2609        assert_eq!(back.payload, payload, "redrive needs the original bytes");
2610        let redrive: AgentEnvelope = decode_named(&back.payload).expect("inner decodes");
2611        assert_eq!(redrive, inner);
2612    }
2613    #[test]
2614    fn given_a_run_lifecycle_status_when_validated_then_should_require_the_correlation() {
2615        // The shape `mark_run` emits: a task status stamped with the run
2616        // metadata key. A task status must carry a correlation, so the
2617        // run-lifecycle mark correlates on the run and only then validates.
2618        let base = AgentEnvelope::status(
2619            RecordId::from_u128(1),
2620            ConversationId::from_u128(2),
2621            "runner".parse().expect("valid agent id"),
2622            OPERATION_TASK,
2623        )
2624        .with_task_state(TaskState::Working)
2625        .with_metadata(METADATA_RUN, "run-1");
2626        assert!(
2627            matches!(
2628                validate(&base),
2629                Err(ValidateError::Missing {
2630                    kind: AgentKind::Status,
2631                    field: "correlation"
2632                })
2633            ),
2634            "a task status without a correlation is rejected"
2635        );
2636        let correlated = base.with_correlation(CorrelationId::from_u128(2));
2637        validate(&correlated)
2638            .expect("the run-lifecycle status validates once it correlates on the run");
2639    }
2640
2641    #[test]
2642    fn given_task_states_when_displayed_and_parsed_then_should_use_the_pinned_kebab_words() {
2643        assert_eq!(TaskState::InputRequired.to_string(), "input-required");
2644        assert_eq!(TaskState::AuthRequired.to_string(), "auth-required");
2645        assert_eq!(TaskState::Unrecognized(42).to_string(), "unrecognized-42");
2646        assert_eq!(
2647            "input-required".parse::<TaskState>().expect("parses"),
2648            TaskState::InputRequired
2649        );
2650        assert!("bogus".parse::<TaskState>().is_err());
2651    }
2652}