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