Skip to main content

laser_wire/
agent.rs

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