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