Skip to main content

aex_core/
capability.rs

1//! Capability bits advertised by agents in their JWS-signed agent card.
2//!
3//! Per ADR-0018, new protocol features ship as capability bits — not as
4//! breaking wire-format bumps. An agent declares what it supports; senders
5//! pick the highest mutually-supported feature at negotiation time.
6//!
7//! The bit-vector representation lets agent cards stay small while keeping
8//! room for ~64 future capabilities. A new capability is added by
9//! introducing a variant here; the variant's `as_bit()` discriminant must
10//! never be reused or renumbered — capability bits are part of the signed
11//! card payload and any reuse breaks historical signatures.
12//!
13//! # Serialization
14//!
15//! On the wire (inside JWS-signed agent cards), a [`CapabilitySet`] is
16//! serialized as a JSON array of capability **string names** (not bit
17//! positions) — see `to_string_array` / `from_string_array`. This keeps
18//! cards human-readable and lets future readers ignore unknown
19//! capabilities forward-compatibly.
20
21use serde::{Deserialize, Serialize};
22
23/// A protocol capability advertised by an agent.
24///
25/// Adding a variant:
26/// 1. Append at the end — never insert in the middle.
27/// 2. Pick the next free `as_bit()` discriminant.
28/// 3. Pick a stable lowercase-kebab-case `as_str()` name.
29/// 4. Add to [`Capability::ALL`].
30/// 5. Document the semantics in `docs/protocol-v2.md`.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Capability {
33    /// Sender and recipient speak wire v2 (`aex-*:v2` prefix). Required
34    /// for any v2 transfer; absence implies v1-only.
35    WireV2,
36    /// Agent publishes a JWS-signed `/.well-known/agent-card.json`
37    /// per ADR-0025. Required for did:web binding.
38    JwsAgentCard,
39    /// Agent supports the cache freshness protocol (`If-None-Match`
40    /// conditional GET on agent card; ADR-0046).
41    CardEtag,
42    /// Agent supports A2A delegation chain receive (bridge adapter from
43    /// Google A2A v1.0 task protocol). Optional, v2.1+ in most deployments.
44    A2ABridge,
45    /// Agent's identity is verified by EtereCitizen reputation index
46    /// on Base L2 (ADR-0040). Present only on did:ethr agent cards
47    /// whose key is registered on-chain.
48    EtereCitizenTrust,
49    /// Agent supports SSRF-resistant outbound HTTP via `aex-net::safe_http`
50    /// (ADR-0045) — relevant when this agent itself acts as a resolver
51    /// for downstream did:web fetches.
52    SafeHttp,
53    /// Agent rejects clock skew > 60s on inbound messages (ADR-0044).
54    /// Absence means v1-style 300s window is still accepted.
55    ClockSkew60s,
56    /// Agent supports the streaming transfer mode (chunked uploads
57    /// with intermediate ack). Reserved for v2.2.
58    StreamingTransfer,
59    /// Agent's responses to inbound intents may be deferred — the
60    /// recipient takes time to decide before approving or rejecting
61    /// a transfer. Senders observing this bit MUST handle an HTTP
62    /// 202 Accepted response and wait for an
63    /// `aex-decision-response:v2` signed message before considering
64    /// the transfer settled.
65    ///
66    /// The protocol takes no position on **who** the decider is
67    /// (human prompt, secondary AI model, policy engine, consensus
68    /// of multiple agents). The bit only signals "I do not answer
69    /// synchronously".
70    ///
71    /// ADR-0049.
72    DeferredDecision,
73}
74
75impl Capability {
76    /// All capabilities known to this build, in stable order.
77    pub const ALL: &'static [Capability] = &[
78        Capability::WireV2,
79        Capability::JwsAgentCard,
80        Capability::CardEtag,
81        Capability::A2ABridge,
82        Capability::EtereCitizenTrust,
83        Capability::SafeHttp,
84        Capability::ClockSkew60s,
85        Capability::StreamingTransfer,
86        Capability::DeferredDecision,
87    ];
88
89    /// Stable bit position in [`CapabilitySet`]. **Never renumber.**
90    pub const fn as_bit(self) -> u8 {
91        match self {
92            Capability::WireV2 => 0,
93            Capability::JwsAgentCard => 1,
94            Capability::CardEtag => 2,
95            Capability::A2ABridge => 3,
96            Capability::EtereCitizenTrust => 4,
97            Capability::SafeHttp => 5,
98            Capability::ClockSkew60s => 6,
99            Capability::StreamingTransfer => 7,
100            Capability::DeferredDecision => 8,
101        }
102    }
103
104    /// Stable wire-string name. **Never rename.**
105    pub const fn as_str(self) -> &'static str {
106        match self {
107            Capability::WireV2 => "wire-v2",
108            Capability::JwsAgentCard => "jws-agent-card",
109            Capability::CardEtag => "card-etag",
110            Capability::A2ABridge => "a2a-bridge",
111            Capability::EtereCitizenTrust => "etere-citizen-trust",
112            Capability::SafeHttp => "safe-http",
113            Capability::ClockSkew60s => "clock-skew-60s",
114            Capability::StreamingTransfer => "streaming-transfer",
115            Capability::DeferredDecision => "deferred-decision",
116        }
117    }
118
119    /// Parse from the stable wire-string name. Returns `None` for unknown
120    /// names — callers that read agent cards must tolerate forward-incompat
121    /// capability names per ADR-0018, so unknown names are silently
122    /// dropped rather than errored.
123    pub fn parse(s: &str) -> Option<Self> {
124        Self::ALL.iter().copied().find(|c| c.as_str() == s)
125    }
126}
127
128/// Bitset of advertised capabilities.
129///
130/// Backed by a `u64` so we have room for 64 future capabilities without
131/// changing the wire size of the agent card.
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
133pub struct CapabilitySet(u64);
134
135impl CapabilitySet {
136    /// Empty set — agent advertises no v2 capabilities.
137    pub const fn empty() -> Self {
138        Self(0)
139    }
140
141    /// Add a capability. Returns `self` for chaining.
142    pub fn with(mut self, cap: Capability) -> Self {
143        self.0 |= 1u64 << cap.as_bit();
144        self
145    }
146
147    /// Check membership.
148    pub fn has(self, cap: Capability) -> bool {
149        (self.0 & (1u64 << cap.as_bit())) != 0
150    }
151
152    /// Iterator over the capabilities present in this set, in
153    /// `Capability::ALL` order.
154    pub fn iter(self) -> impl Iterator<Item = Capability> {
155        Capability::ALL
156            .iter()
157            .copied()
158            .filter(move |c| self.has(*c))
159    }
160
161    /// Render as the canonical JSON array of capability string names
162    /// embedded in JWS-signed agent cards.
163    pub fn to_string_array(self) -> Vec<&'static str> {
164        self.iter().map(Capability::as_str).collect()
165    }
166
167    /// Build from the wire JSON array. Unknown names are silently
168    /// skipped (forward-compat) per ADR-0018 — readers must tolerate
169    /// capability names they don't recognize without erroring.
170    pub fn from_string_array<I, S>(items: I) -> Self
171    where
172        I: IntoIterator<Item = S>,
173        S: AsRef<str>,
174    {
175        let mut set = Self::empty();
176        for item in items {
177            if let Some(cap) = Capability::parse(item.as_ref()) {
178                set = set.with(cap);
179            }
180        }
181        set
182    }
183
184    /// Raw bitset (for testing / debug only).
185    pub const fn bits(self) -> u64 {
186        self.0
187    }
188}
189
190impl Serialize for CapabilitySet {
191    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
192        // Serialize as an array of stable string names — survives
193        // re-numbering of variants because we never re-number, but
194        // also survives readers from older builds that don't know
195        // newer string names.
196        self.to_string_array().serialize(s)
197    }
198}
199
200impl<'de> Deserialize<'de> for CapabilitySet {
201    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
202        let v: Vec<String> = Vec::deserialize(d)?;
203        Ok(Self::from_string_array(v))
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn empty_set_has_no_caps() {
213        let set = CapabilitySet::empty();
214        for cap in Capability::ALL {
215            assert!(!set.has(*cap), "empty set should not have {:?}", cap);
216        }
217    }
218
219    #[test]
220    fn add_and_query() {
221        let set = CapabilitySet::empty()
222            .with(Capability::WireV2)
223            .with(Capability::JwsAgentCard);
224        assert!(set.has(Capability::WireV2));
225        assert!(set.has(Capability::JwsAgentCard));
226        assert!(!set.has(Capability::A2ABridge));
227    }
228
229    #[test]
230    fn bits_are_stable() {
231        // CRITICAL: changing these breaks deployed agent cards. If a
232        // test here fails after a code change, you've renumbered a
233        // capability — revert and ADD at the end instead.
234        assert_eq!(Capability::WireV2.as_bit(), 0);
235        assert_eq!(Capability::JwsAgentCard.as_bit(), 1);
236        assert_eq!(Capability::CardEtag.as_bit(), 2);
237        assert_eq!(Capability::A2ABridge.as_bit(), 3);
238        assert_eq!(Capability::EtereCitizenTrust.as_bit(), 4);
239        assert_eq!(Capability::SafeHttp.as_bit(), 5);
240        assert_eq!(Capability::ClockSkew60s.as_bit(), 6);
241        assert_eq!(Capability::StreamingTransfer.as_bit(), 7);
242        assert_eq!(Capability::DeferredDecision.as_bit(), 8);
243    }
244
245    #[test]
246    fn names_are_stable() {
247        assert_eq!(Capability::WireV2.as_str(), "wire-v2");
248        assert_eq!(Capability::JwsAgentCard.as_str(), "jws-agent-card");
249        assert_eq!(Capability::CardEtag.as_str(), "card-etag");
250        assert_eq!(Capability::A2ABridge.as_str(), "a2a-bridge");
251        assert_eq!(
252            Capability::EtereCitizenTrust.as_str(),
253            "etere-citizen-trust"
254        );
255        assert_eq!(Capability::SafeHttp.as_str(), "safe-http");
256        assert_eq!(Capability::ClockSkew60s.as_str(), "clock-skew-60s");
257        assert_eq!(Capability::StreamingTransfer.as_str(), "streaming-transfer");
258        assert_eq!(Capability::DeferredDecision.as_str(), "deferred-decision");
259    }
260
261    #[test]
262    fn parse_roundtrip() {
263        for cap in Capability::ALL {
264            let parsed = Capability::parse(cap.as_str()).unwrap();
265            assert_eq!(parsed, *cap);
266        }
267        assert!(Capability::parse("does-not-exist").is_none());
268    }
269
270    #[test]
271    fn iter_in_canonical_order() {
272        let set = CapabilitySet::empty()
273            .with(Capability::StreamingTransfer)
274            .with(Capability::WireV2)
275            .with(Capability::JwsAgentCard);
276        let order: Vec<_> = set.iter().collect();
277        assert_eq!(
278            order,
279            vec![
280                Capability::WireV2,
281                Capability::JwsAgentCard,
282                Capability::StreamingTransfer
283            ]
284        );
285    }
286
287    #[test]
288    fn serde_roundtrip_via_string_array() {
289        let set = CapabilitySet::empty()
290            .with(Capability::WireV2)
291            .with(Capability::JwsAgentCard)
292            .with(Capability::CardEtag);
293        let json = serde_json::to_string(&set).unwrap();
294        assert_eq!(json, r#"["wire-v2","jws-agent-card","card-etag"]"#);
295        let back: CapabilitySet = serde_json::from_str(&json).unwrap();
296        assert_eq!(set, back);
297    }
298
299    #[test]
300    fn deserialize_skips_unknown_names() {
301        // Forward-compat: a v2.3 agent advertising "post-quantum-sig"
302        // must NOT cause a v2.0 reader to error.
303        let json = r#"["wire-v2","post-quantum-sig","jws-agent-card"]"#;
304        let set: CapabilitySet = serde_json::from_str(json).unwrap();
305        assert!(set.has(Capability::WireV2));
306        assert!(set.has(Capability::JwsAgentCard));
307        // Set must not have any phantom capability for "post-quantum-sig".
308        assert_eq!(set.to_string_array().len(), 2);
309    }
310
311    #[test]
312    fn duplicate_names_idempotent() {
313        let set = CapabilitySet::from_string_array(["wire-v2", "wire-v2", "wire-v2"]);
314        assert!(set.has(Capability::WireV2));
315        assert_eq!(set.to_string_array().len(), 1);
316    }
317
318    #[test]
319    fn empty_array_is_empty_set() {
320        let set: CapabilitySet = serde_json::from_str("[]").unwrap();
321        assert_eq!(set, CapabilitySet::empty());
322        assert_eq!(set.bits(), 0);
323    }
324
325    #[test]
326    fn all_caps_set() {
327        let mut set = CapabilitySet::empty();
328        for cap in Capability::ALL {
329            set = set.with(*cap);
330        }
331        for cap in Capability::ALL {
332            assert!(set.has(*cap));
333        }
334    }
335}