open-standards/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs
Transaction Science 002160be2f jcp-a2a: an unverified AP2 mandate could set the ceiling, and name any payee
Two soundness holes on the A2A path, both verified in the code before fixing.

**1. A forged mandate minted a real grant.** `ingest_ap2_mandate` is a JSON
field reader: it verifies no signature, resolves no issuer key and consults no
revocation list. Its output then became a ceiling the gate enforces, via
`joule_budget()` / `funds_ceiling()`. So the gate was sound and its *limit*
came from unauthenticated input — anyone who could hand us JSON could set the
budget.

Parsing and trusting are now two types. `ingest_ap2_mandate` returns
`UnverifiedAp2Mandate`, which has no ceiling accessors at all; the only way to
reach them is `accepted_as_verified(authority)`, which refuses a blank
authority and keeps the name on the mandate behind `Ap2Mandate::authority()`.
This crate deliberately does not implement AP2's own signature format — the
mandate is a layered verifiable credential whose verification needs issuer-key
resolution JCP has no authority over — so the honest boundary is that the host
verifies and says what did. The provenance of an *authority* is the counterpart
to the provenance of a *measurement*: the receipt already says where its joules
came from, and now a ceiling can say where its authority came from.

That this is a real boundary and not a comment is what the compiler showed:
every existing call site that treated parsed JSON as an enforceable ceiling
stopped compiling and had to declare an authority.

**2. The payee was read and then never consulted.** `Ap2Mandate.payee` appeared
exactly once outside parsing — in a test assertion. So a mandate authorizing
`attacker.example` minted a grant that spent against `planner.example`'s work:
one party's authorization spent on another's. `jcp-x402` already closes this by
putting the merchant in the grant's audience ("a charge for a different
merchant ... still denies"); the pattern simply was not applied here. Both
`from_mandate` and `from_mandate_priced` now refuse unless `card.is_payee()`,
which binds the payee to the agent id, the name, or the host of the service URL
— because an AP2 payee is a merchant identifier while an A2A agent id is a
service URL, so `research.example` must bind to `https://research.example/a2a`.
Userinfo and port do not identify the party and are stripped. An empty payee
binds to nothing: a mandate that names no payee authorizes no one.

Worth noting what did *not* have to change: both real cards in the tree already
named matching payees (`planner.example`, `research.example`), so the binding
was always the intent — it just was not enforced. Only tests using placeholder
payees (`"p"`, `"m"`) had to be given real ones, which is the defect in
miniature.

14 tests pass, 0 clippy findings in this crate (the two in the workspace are
pre-existing in `jcp-schema::flow` on main). Three new invariants: a ceiling
needs a non-blank authority, a mandate for another payee cannot mint through
either constructor, and a payee binds by id/name/host but not by suffix
(`planner.example.evil` is refused).

Found by the 2026-10-04 pillar sweep, which flagged both against
`ingest_ap2_mandate` while verifying AP2/Verifiable-Intent under FIDO; I
confirmed each against the source before changing anything.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 07:22:11 -04:00

944 lines
38 KiB
Rust

//! The **A2A + AP2 profile of JCP** — the open agent-interop wire, made
//! capability-scoped, joule-budgeted, decided outside the model, and sealed.
//!
//! [A2A](https://a2a-protocol.org) is the open agent-to-agent wire: an **Agent
//! Card** advertises an agent's `skills`, and a peer drives them over JSON-RPC
//! (`message/send`). [AP2](https://ap2-protocol.org) layers payment **mandates**
//! that authorize an agent to spend on a principal's behalf. Both describe *what*
//! an agent may do and *how much* it may spend — but the authorization lives in
//! the wire, enforced by whoever holds the transport, denominated in dollars.
//!
//! JCP already has the enforcement primitive: a signed **Grant** = capability ⊗
//! joule-budget, decided outside the model and sealed in a JCR-1 receipt
//! ([`jcp_bridge`] already frames an A2A task as a [`Frame`]). This crate is the
//! ingest that closes the loop:
//!
//! - [`ingest_agent_card`] parses an A2A Agent Card; [`capabilities_from_card`]
//! turns each **skill** into a content-addressed JCP [`Capability`] (the
//! resource is `a2a:<agent>:<skill>`, an audience an attacker can't spoof by
//! name). The Agent Card *advertises* the skills; the profile *binds* a
//! capability to each.
//! - [`ingest_ap2_mandate`] parses an AP2 mandate into an
//! [`UnverifiedAp2Mandate`] — parsing authenticates nothing, and a mandate's
//! amount becomes a ceiling the gate enforces, so the two steps are kept
//! apart: [`UnverifiedAp2Mandate::accepted_as_verified`] takes the ceilings
//! once the host has verified it, naming the authority that did, and the
//! mandate carries that name. Minting also refuses a mandate whose payee is
//! not the agent being called. A **joule**-denominated
//! mandate reads as an energy ceiling ([`Ap2Mandate::joule_budget`]); a
//! **currency**-denominated one — what the payments ecosystem actually emits —
//! reads as a *money* ceiling ([`Ap2Mandate::funds_ceiling`]). The profile
//! never invents a currency→joule exchange rate: the two are carried as two
//! independent ceilings (SPEC §4.8), enforced separately.
//! - [`A2aProfile`] mints one capability-scoped grant per skill and gates every
//! A2A task against it through a [`jcp_bridge::Bridge`]: a call to a skill the
//! Agent Card did not advertise — or one past *either* ceiling — is denied
//! **before** it reaches the peer, and the denial seals just like an allow,
//! recording which ceiling bound. For a currency mandate,
//! [`A2aProfile::from_mandate_priced`] takes the money ceiling from the mandate
//! and the joule ceiling from the operator (the mandate has no authority over
//! energy), then [`A2aProfile::serve_task_priced`] enforces both. The model's
//! judgement never enters the decision; the held capability and the two
//! budgets do.
//!
//! Ingest, not compete: the A2A/AP2 wire is the surface; JCP contributes the
//! capability, the energy budget, and the cryptographic audit it lacks.
use std::collections::BTreeMap;
use ed25519_dalek::SigningKey;
use jcp_bridge::{a2a_resource, Bridge, Gated};
use jcp_grant::issue;
use jcp_runtime::Price;
use jcp_schema::{Capability, Did, Energy, Funds, Grant, SignedGrant};
use serde_json::Value;
/// Errors ingesting an Agent Card, an AP2 mandate, or an A2A request.
#[derive(Debug, thiserror::Error)]
pub enum A2aError {
/// The document was not the expected JSON shape.
#[error("malformed {what}: {detail}")]
Malformed {
/// Which document.
what: &'static str,
/// What was wrong.
detail: String,
},
/// The grant for a skill could not be issued.
#[error("grant issue failed: {0}")]
Grant(#[from] jcp_grant::GrantError),
}
fn malformed(what: &'static str, detail: impl Into<String>) -> A2aError {
A2aError::Malformed { what, detail: detail.into() }
}
// ───────────────────────────── A2A Agent Card ─────────────────────────────
/// One advertised capability of an A2A agent (an A2A `AgentSkill`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentSkill {
/// The skill id (the action a grant authorizes).
pub id: String,
/// Human-readable name.
pub name: String,
/// What the skill does.
pub description: String,
/// Free-form tags.
pub tags: Vec<String>,
}
/// An A2A **Agent Card**: the agent's identity and the skills it advertises.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentCard {
/// The agent name.
pub name: String,
/// The agent's service URL (the stable identity a capability binds to).
pub url: String,
/// The advertised version.
pub version: String,
/// The skills the agent advertises.
pub skills: Vec<AgentSkill>,
}
impl AgentCard {
/// The stable identity a capability's audience binds to: the service URL,
/// falling back to the name.
pub fn agent_id(&self) -> &str {
if self.url.is_empty() {
&self.name
} else {
&self.url
}
}
/// Whether the card advertises a skill with this id.
pub fn advertises(&self, skill_id: &str) -> bool {
self.skills.iter().any(|s| s.id == skill_id)
}
/// Whether this card's agent is the payee an AP2 mandate authorizes.
///
/// A mandate names who may be paid; a card names who will be called. If
/// they are not the same party, the mandate is authority for *someone
/// else's* work, and spending against it is spending outside it. This is
/// the binding `jcp-x402` already makes by putting the merchant in the
/// grant's audience, applied to the A2A path, which read the payee and
/// then never consulted it.
///
/// Matched against the agent id, the host of its service URL, and the
/// name — because an AP2 payee is a merchant identifier and an A2A agent
/// id is a service URL, so `research.example` must bind to
/// `https://research.example/a2a`. An empty payee matches nothing: a
/// mandate that names no payee authorizes no one.
pub fn is_payee(&self, payee: &str) -> bool {
let payee = payee.trim();
if payee.is_empty() {
return false;
}
let eq = |a: &str| !a.is_empty() && a.eq_ignore_ascii_case(payee);
eq(self.agent_id()) || eq(&self.name) || eq(url_host(&self.url))
}
}
/// The host of a URL — `https://research.example/a2a` -> `research.example` —
/// or the whole string when it carries no scheme or path.
fn url_host(url: &str) -> &str {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let host = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
// Strip userinfo and port, neither of which identifies the party.
let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
host.split_once(':').map_or(host, |(h, _)| h)
}
/// Parse an A2A Agent Card from its JSON form (camelCase per the A2A spec;
/// unknown fields are tolerated).
pub fn ingest_agent_card(card: &Value) -> Result<AgentCard, A2aError> {
let obj = card.as_object().ok_or_else(|| malformed("agent card", "not a JSON object"))?;
let s = |k: &str| obj.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let name = s("name");
let url = s("url");
if name.is_empty() && url.is_empty() {
return Err(malformed("agent card", "neither `name` nor `url` present"));
}
let skills = obj
.get("skills")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(parse_skill).collect())
.unwrap_or_default();
Ok(AgentCard { name, url, version: s("version"), skills })
}
fn parse_skill(v: &Value) -> Option<AgentSkill> {
let id = v.get("id").and_then(Value::as_str)?.to_string();
if id.is_empty() {
return None;
}
let str_field = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let tags = v
.get("tags")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_string)).collect())
.unwrap_or_default();
Some(AgentSkill { id, name: str_field("name"), description: str_field("description"), tags })
}
/// The JCP capabilities an Agent Card advertises — one content-addressed
/// capability per skill, bound to the `a2a:<agent>:<skill>` audience.
pub fn capabilities_from_card(card: &AgentCard) -> Vec<Capability> {
let agent = card.agent_id();
card.skills
.iter()
.map(|s| Capability::new(a2a_resource(agent, &s.id), [s.id.clone()]))
.collect()
}
// ───────────────────────────── AP2 mandate ─────────────────────────────
/// An AP2 mandate exactly as parsed, before anything has authenticated it.
///
/// This type exists because the fields below are attacker-controlled until
/// something checks them. [`ingest_ap2_mandate`] reads JSON; it verifies no
/// signature, resolves no issuer key and consults no revocation list, because
/// the bytes it is handed carry none of that by themselves. A mandate's
/// `max_amount` becomes a *ceiling the gate enforces*, so a mandate accepted
/// without authentication is an attacker setting the budget — the gate stays
/// sound and its limit does not.
///
/// There is deliberately no path from here to a ceiling except
/// [`Self::accepted_as_verified`], which makes the caller name what did the
/// verifying. Parsing and trusting are two steps, and this type is the one
/// that has not happened yet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnverifiedAp2Mandate {
/// The payee / merchant the mandate claims to authorize.
pub payee: String,
/// The currency the claimed ceiling is denominated in.
pub currency: String,
/// The claimed ceiling, in the currency's minor unit.
pub max_amount: u64,
}
impl UnverifiedAp2Mandate {
/// Accept this mandate's ceilings, recording what authenticated it.
///
/// `authority` names the thing that verified the mandate — a wallet, an
/// issuer key id, an SD-JWT verification, a named host subsystem. It is
/// kept on the mandate and reachable through [`Ap2Mandate::authority`], so
/// a ceiling can be traced to the authority that vouched for it rather
/// than appearing from parsed JSON. An empty or blank `authority` is
/// refused: a mandate nothing vouched for is exactly the case this type
/// exists to stop.
///
/// This crate does not implement AP2's own signature format. The mandate
/// is a layered verifiable credential whose verification needs issuer-key
/// resolution that JCP has no authority over, so the honest boundary is
/// here: the host verifies, and says so on the record.
pub fn accepted_as_verified(
self,
authority: impl Into<String>,
) -> Result<Ap2Mandate, A2aError> {
let authority = authority.into();
if authority.trim().is_empty() {
return Err(malformed(
"ap2 mandate",
"accepted_as_verified needs the authority that verified it; \
a ceiling nothing vouched for is not a ceiling",
));
}
Ok(Ap2Mandate {
payee: self.payee,
currency: self.currency,
max_amount: self.max_amount,
authority,
})
}
}
/// An AP2 payment mandate whose ceilings may be enforced: an authorization for
/// an agent to spend on a principal's behalf, up to a ceiling, **with the
/// authority that authenticated it on the record**.
///
/// Reachable only through [`UnverifiedAp2Mandate::accepted_as_verified`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Ap2Mandate {
/// The payee / merchant the mandate authorizes.
pub payee: String,
/// The currency the ceiling is denominated in (`uJ` / `µJ` / `joule` for a
/// joule-denominated mandate; an ISO currency otherwise).
pub currency: String,
/// The authorized ceiling, in the currency's minor unit.
pub max_amount: u64,
/// What authenticated this mandate. Private so the only way to hold an
/// `Ap2Mandate` is to have named one.
authority: String,
}
impl Ap2Mandate {
/// What authenticated this mandate, as the host named it.
///
/// The provenance of an *authority* is the counterpart to the provenance
/// of a *measurement*: a receipt already says where its joules came from,
/// and this says where its ceiling's authority came from.
pub fn authority(&self) -> &str {
&self.authority
}
/// The joule budget this mandate authorizes, in microjoules — `Some` when the
/// mandate is **denominated in joules** (the natural form for energy-priced
/// agent work). A currency-denominated mandate returns `None`: the profile
/// never invents a currency→joule exchange rate; the joule ceiling must be
/// supplied alongside it.
pub fn joule_budget(&self) -> Option<u64> {
match self.currency.trim().to_ascii_lowercase().as_str() {
"uj" | "µj" | "ujoule" | "microjoule" | "microjoules" => Some(self.max_amount),
"j" | "joule" | "joules" => self.max_amount.checked_mul(1_000_000),
"mj" | "millijoule" | "millijoules" => self.max_amount.checked_mul(1_000),
_ => None,
}
}
/// The **money** ceiling this mandate authorizes (SPEC §4.8) — `Some` for a
/// currency-denominated mandate, which is what the payments ecosystem
/// actually emits.
///
/// This is the counterpart to [`joule_budget`](Self::joule_budget), and the
/// two are deliberately disjoint: a mandate denominated in joules is an
/// energy ceiling and yields no money ceiling; one denominated in a currency
/// is a money ceiling and yields no joule ceiling. **No exchange rate is
/// invented in either direction.** A currency mandate therefore does not
/// bound energy at all — the operator supplies that ceiling, because the
/// mandate has no authority over it. Two ceilings, two sources of authority:
/// exactly why they are enforced separately rather than converted.
pub fn funds_ceiling(&self) -> Option<Funds> {
if self.joule_budget().is_some() {
return None; // joule-denominated: an energy ceiling, not money
}
Some(Funds::new(self.currency.trim(), self.max_amount))
}
}
/// Parse an AP2 mandate from its JSON form. Reads the payee, the currency, and
/// the authorized ceiling (`maxAmount` / `amount`); tolerant of the surrounding
/// IntentMandate / CartMandate / PaymentMandate envelope.
///
/// **This authenticates nothing** — hence [`UnverifiedAp2Mandate`]. Call
/// [`UnverifiedAp2Mandate::accepted_as_verified`] once the host has verified
/// it, naming what did.
pub fn ingest_ap2_mandate(mandate: &Value) -> Result<UnverifiedAp2Mandate, A2aError> {
// The amount may sit at the top level or under a `paymentMandate` / `cart`.
let root = mandate
.get("paymentMandate")
.or_else(|| mandate.get("cart"))
.unwrap_or(mandate);
let obj = root.as_object().ok_or_else(|| malformed("ap2 mandate", "not a JSON object"))?;
let payee = obj
.get("payee")
.or_else(|| obj.get("merchant"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let currency = obj
.get("currency")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let max_amount = obj
.get("maxAmount")
.or_else(|| obj.get("amount"))
.and_then(Value::as_u64)
.ok_or_else(|| malformed("ap2 mandate", "no `maxAmount`/`amount` uint"))?;
if currency.is_empty() {
return Err(malformed("ap2 mandate", "no `currency`"));
}
Ok(UnverifiedAp2Mandate { payee, currency, max_amount })
}
/// Refuse to mint against a mandate that authorizes a different party.
///
/// The mandate is the authority for the spend; the card is who gets called.
/// Minting when they disagree would spend one merchant's authorization on
/// another's work, and no later gate can catch it — by then the ceiling is
/// already the grant's.
fn refuse_unless_payee(card: &AgentCard, mandate: &Ap2Mandate) -> Result<(), A2aError> {
if card.is_payee(&mandate.payee) {
return Ok(());
}
Err(malformed(
"ap2 mandate",
format!(
"mandate authorizes payee `{}`, but the agent card is `{}`: a mandate \
for one party is not authority to spend on another's work",
mandate.payee,
card.agent_id()
),
))
}
// ───────────────────────────── the profile ─────────────────────────────
/// The result of gating one A2A task: re-exported from [`jcp_bridge`].
pub use jcp_bridge::Gated as TaskGate;
/// An A2A agent's advertised skills, minted into capability-scoped joule-budgeted
/// JCP grants, with every task gated and sealed.
///
/// Construct it from an Agent Card and a joule budget (typically read from an AP2
/// mandate), then call [`A2aProfile::serve_task`] for each A2A invocation. The
/// host forwards the task to the real A2A peer **only** when the gate allows.
pub struct A2aProfile {
bridge: Bridge,
agent: String,
caller: Did,
grants: BTreeMap<String, SignedGrant>,
anchor: Option<SignedGrant>,
steward: String,
steward_pk: [u8; 32],
receipts: Vec<Vec<u8>>,
now: u64,
}
impl A2aProfile {
/// Mint one grant per advertised skill, each scoped to that skill's audience
/// and capped at `budget_uj`, and stand up a sealing gateway.
///
/// `issuer_sk` is the principal authorizing the agent (an AP2 mandate's
/// signer); `subject` is the calling agent that holds the grants and drives
/// the tasks; `steward_sk` seals every decision.
pub fn new(
card: &AgentCard,
budget_uj: u64,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, A2aError> {
Self::new_priced(card, budget_uj, None, issuer_sk, subject, steward_sk, steward)
}
/// Mint one **dual-ceiling** grant per advertised skill (SPEC §4.8): a joule
/// ceiling and, optionally, a money ceiling — each enforced independently.
#[allow(clippy::too_many_arguments)]
pub fn new_priced(
card: &AgentCard,
budget_uj: u64,
budget_funds: Option<Funds>,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, A2aError> {
let issuer = Did::from_verifying_key(&issuer_sk.verifying_key());
let agent = card.agent_id().to_string();
let mut grants = BTreeMap::new();
for cap in capabilities_from_card(card) {
let action = cap.actions.first().cloned().unwrap_or_default();
let grant = Grant {
issuer,
subject,
capability: cap,
budget_uj,
budget_funds: budget_funds.clone(),
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
nonce: [0; 16],
};
grants.insert(action, issue(issuer_sk, grant)?);
}
if grants.is_empty() {
return Err(malformed("agent card", "advertises no skills to bind"));
}
let anchor = grants.values().next().cloned();
let steward = steward.into();
let steward_pk = steward_sk.verifying_key().to_bytes();
Ok(Self {
bridge: Bridge::new(steward_sk, steward.clone()),
agent,
caller: subject,
grants,
anchor,
steward,
steward_pk,
receipts: Vec::new(),
now: 0,
})
}
/// Mint from an AP2 mandate directly — the mandate's joule authorization is
/// the per-skill budget. Errors if the mandate is not joule-denominated (the
/// profile invents no exchange rate; use [`A2aProfile::new`] with an explicit
/// joule budget for a currency mandate).
pub fn from_mandate(
card: &AgentCard,
mandate: &Ap2Mandate,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, A2aError> {
refuse_unless_payee(card, mandate)?;
let budget = mandate.joule_budget().ok_or_else(|| {
malformed("ap2 mandate", format!("`{}` is not joule-denominated", mandate.currency))
})?;
Self::new(card, budget, issuer_sk, subject, steward_sk, steward)
}
/// Ingest a **currency-denominated** AP2 mandate — the form the payments
/// ecosystem actually emits — as a dual-ceiling profile (SPEC §4.8).
///
/// The mandate supplies the **money** ceiling; the caller supplies the
/// **joule** ceiling, because the mandate has no authority over energy and
/// JCP will not invent a rate to derive one. Both are then enforced
/// independently on every task, and the receipt records which one bound.
///
/// This is what a real AP2 mandate (`currency: "USD"`) resolves to: before
/// dual ceilings it yielded no joule ceiling at all and could not be gated.
#[allow(clippy::too_many_arguments)]
pub fn from_mandate_priced(
card: &AgentCard,
mandate: &Ap2Mandate,
budget_uj: u64,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, A2aError> {
refuse_unless_payee(card, mandate)?;
// A joule-denominated mandate has no money ceiling; a currency one has
// no joule ceiling. Either way the caller's `budget_uj` bounds energy.
let funds = mandate.funds_ceiling();
Self::new_priced(card, budget_uj, funds, issuer_sk, subject, steward_sk, steward)
}
/// Serve an A2A task against **both** ceilings — the priced dual of
/// [`serve_task`](Self::serve_task). `price` is what this task costs in
/// money; `cost_uj` is what it costs in energy.
pub fn serve_task_priced(
&mut self,
skill_id: &str,
payload: &[u8],
cost_uj: u64,
price: &Price,
) -> Gated {
let signed = self
.grants
.get(skill_id)
.or(self.anchor.as_ref())
.expect("profile always holds at least one grant (new rejects empty cards)")
.clone();
let gated = self.bridge.call_a2a_priced(
&signed,
self.caller,
&self.agent,
skill_id,
payload,
Energy::estimator(cost_uj),
price,
self.now,
);
self.receipts.push(gated.receipt.clone());
gated
}
/// Money debited against a skill's grant, in its ceiling's minor units.
pub fn spent_minor(&self, skill_id: &str) -> u64 {
self.grants
.get(skill_id)
.map_or(0, |sg| self.bridge.runtime().spent_minor(&sg.grant.id()))
}
/// Gate one A2A task: `skill_id` is the skill the task targets, `payload` its
/// message bytes, `cost_uj` the energy to attribute. On allow the cost debits
/// the skill's grant; either way the decision seals a JCR-1 receipt. Forward
/// to the real A2A peer only when the returned gate `allowed()`.
pub fn serve_task(&mut self, skill_id: &str, payload: &[u8], cost_uj: u64) -> Gated {
// An ungranted skill still decides (and seals) against an anchor grant:
// its frame's audience can't match, so the runtime denies it — recorded.
let signed = self
.grants
.get(skill_id)
.or(self.anchor.as_ref())
.expect("profile always holds at least one grant (new rejects empty cards)")
.clone();
let gated = self.bridge.call_a2a(
&signed,
self.caller,
&self.agent,
skill_id,
payload,
Energy::estimator(cost_uj),
self.now,
);
self.receipts.push(gated.receipt.clone());
gated
}
/// The granted skill ids, sorted.
pub fn granted_skills(&self) -> Vec<&str> {
self.grants.keys().map(String::as_str).collect()
}
/// Total joules debited across all skill grants, in µJ.
pub fn spent_uj(&self) -> u64 {
self.grants.values().map(|sg| self.bridge.spent(&sg.grant.id())).sum()
}
/// The sealed JCR-1 decision receipts (allow + deny), in order.
pub fn receipts(&self) -> &[Vec<u8>] {
&self.receipts
}
/// The sealing steward id (the `kid` a verifier resolves).
pub fn steward(&self) -> &str {
&self.steward
}
/// The steward's public key — a third party verifies the receipts with this.
pub fn steward_pubkey(&self) -> [u8; 32] {
self.steward_pk
}
}
// ───────────────────────── A2A request parsing ─────────────────────────
/// The target skill and message bytes of an A2A `message/send` request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaskRequest {
/// The skill the task targets (`params.metadata.skillId`).
pub skill_id: String,
/// The flattened message text (the `params.message.parts` text).
pub payload: String,
}
/// Parse an A2A JSON-RPC `message/send` request into the target skill and its
/// message text. The skill id is taken from `params.metadata.skillId`; the
/// payload concatenates the text of the message's `parts`.
pub fn parse_message_send(req: &Value) -> Result<TaskRequest, A2aError> {
if req.get("method").and_then(Value::as_str) != Some("message/send") {
return Err(malformed("a2a request", "method is not `message/send`"));
}
let params = req.get("params").ok_or_else(|| malformed("a2a request", "no `params`"))?;
let skill_id = params
.get("metadata")
.and_then(|m| m.get("skillId"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let message = params.get("message").ok_or_else(|| malformed("a2a request", "no `message`"))?;
let payload = message
.get("parts")
.and_then(Value::as_array)
.map(|parts| {
parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
Ok(TaskRequest { skill_id, payload })
}
#[cfg(test)]
mod tests {
use super::*;
use jcp_schema::Verdict;
use serde_json::json;
fn sk(s: u8) -> SigningKey {
SigningKey::from_bytes(&[s; 32])
}
fn sample_card() -> Value {
json!({
"name": "planner-agent",
"url": "https://planner.example/a2a",
"version": "1.0.0",
"capabilities": { "streaming": true, "pushNotifications": false },
"skills": [
{ "id": "plan", "name": "Plan", "description": "Decompose a goal", "tags": ["reasoning"] },
{ "id": "summarize", "name": "Summarize", "description": "Summarize text", "tags": ["nlp"] }
]
})
}
#[test]
fn ingests_card_into_capabilities() {
let card = ingest_agent_card(&sample_card()).unwrap();
assert_eq!(card.name, "planner-agent");
assert_eq!(card.skills.len(), 2);
assert!(card.advertises("plan"));
let caps = capabilities_from_card(&card);
assert_eq!(caps.len(), 2);
// The capability binds to the content-addressed a2a:<agent>:<skill>.
assert_eq!(caps[0].resource, a2a_resource(card.agent_id(), "plan"));
assert_eq!(caps[0].actions, vec!["plan".to_string()]);
}
#[test]
fn rejects_empty_card() {
assert!(ingest_agent_card(&json!({ "skills": [] })).is_err());
}
#[test]
fn ap2_joule_budget_maps_units_and_refuses_currency() {
let uj = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 5_000 }))
.unwrap().accepted_as_verified("test-wallet").unwrap();
assert_eq!(uj.joule_budget(), Some(5_000));
let j = ingest_ap2_mandate(&json!({ "merchant": "m", "currency": "J", "amount": 3 }))
.unwrap().accepted_as_verified("test-wallet").unwrap();
assert_eq!(j.joule_budget(), Some(3_000_000));
// A currency mandate yields no joule budget — no invented exchange rate.
let usd = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "USD", "maxAmount": 100 }))
.unwrap().accepted_as_verified("test-wallet").unwrap();
assert_eq!(usd.joule_budget(), None);
}
/// A ceiling must name what vouched for it. Parsing JSON vouches for
/// nothing, so a blank authority is refused rather than accepted as a
/// weak one.
#[test]
fn a_ceiling_needs_an_authority_that_is_not_blank() {
let parsed =
ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 9 })).unwrap();
for blank in ["", " ", "\t"] {
assert!(
parsed.clone().accepted_as_verified(blank).is_err(),
"a blank authority ({blank:?}) was accepted"
);
}
let ok = parsed.accepted_as_verified("wallet:did:example:w1").unwrap();
assert_eq!(ok.authority(), "wallet:did:example:w1");
assert_eq!(ok.joule_budget(), Some(9));
}
/// The soundness hole this closes: a mandate naming a different payee is
/// authority for someone else's work, and minting against it spent one
/// party's authorization on another's. `jcp-x402` already bound the
/// merchant into the grant audience; the A2A path read the payee and never
/// consulted it.
#[test]
fn a_mandate_for_another_payee_cannot_mint() {
let card = ingest_agent_card(&sample_card()).unwrap(); // planner.example
let wrong = ingest_ap2_mandate(
&json!({ "payee": "attacker.example", "currency": "uJ", "maxAmount": 1_000_000 }),
)
.unwrap()
.accepted_as_verified("test-wallet")
.unwrap();
let err = A2aProfile::from_mandate(
&card,
&wrong,
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:a2a-steward",
)
.err()
.expect("a mandate for attacker.example minted against planner.example");
assert!(
format!("{err}").contains("attacker.example"),
"the refusal must name the payee it refused: {err}"
);
// The priced constructor is the same authority question, so it refuses too.
assert!(
A2aProfile::from_mandate_priced(
&card,
&wrong,
50_000,
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:a2a-steward",
)
.is_err(),
"from_mandate_priced accepted another party's mandate"
);
}
/// A payee binds to the agent by id, by name, or by the host of its URL —
/// because an AP2 payee is a merchant identifier and an A2A agent id is a
/// service URL. A mandate naming no payee authorizes no one.
#[test]
fn a_payee_binds_to_the_agent_by_id_name_or_host() {
let card = ingest_agent_card(&sample_card()).unwrap();
for ok in ["planner.example", "PLANNER.EXAMPLE", "planner-agent", "https://planner.example/a2a"] {
assert!(card.is_payee(ok), "{ok} should bind");
}
for bad in ["", " ", "attacker.example", "planner.example.evil", "example"] {
assert!(!card.is_payee(bad), "{bad:?} must not bind");
}
// Port and userinfo do not identify the party.
let c = ingest_agent_card(&json!({
"name": "n", "url": "https://[email protected]:8443/a2a", "skills": [{"id":"s"}]
}))
.unwrap();
assert!(c.is_payee("planner.example"));
}
#[test]
fn ap2_mandate_envelope_unwrapped() {
let m = ingest_ap2_mandate(&json!({
"paymentMandate": { "payee": "store", "currency": "mJ", "maxAmount": 7 }
}))
.unwrap().accepted_as_verified("test-wallet").unwrap();
assert_eq!(m.payee, "store");
assert_eq!(m.joule_budget(), Some(7_000)); // 7 mJ → 7000 µJ
}
#[test]
fn advertised_skill_allowed_unadvertised_denied_both_sealed() {
let card = ingest_agent_card(&sample_card()).unwrap();
let mut profile = A2aProfile::new(
&card,
1_000_000,
&sk(1), // principal / mandate signer
Did::from_verifying_key(&sk(2).verifying_key()), // calling agent
sk(7), // steward
"did:example:a2a-steward",
)
.unwrap();
// An advertised skill is allowed and the cost is debited.
let g = profile.serve_task("plan", b"decompose: ship the feature", 40_000);
assert!(g.allowed());
// A skill the Agent Card never advertised is denied before it reaches
// the peer — the model's choice never enters the decision.
let bad = profile.serve_task("delete_account", b"drop everything", 1);
assert!(!bad.allowed());
assert!(matches!(bad.decision.verdict, Verdict::Deny(_)));
// Both decisions sealed a receipt.
assert_eq!(profile.receipts().len(), 2);
assert_eq!(profile.granted_skills(), vec!["plan", "summarize"]);
assert_eq!(profile.spent_uj(), 40_000);
}
#[test]
fn ap2_budget_caps_task_spend() {
let card = ingest_agent_card(&sample_card()).unwrap();
let mandate =
ingest_ap2_mandate(&json!({ "payee": "planner.example", "currency": "uJ", "maxAmount": 50_000 }))
.unwrap().accepted_as_verified("test-wallet").unwrap();
let mut profile = A2aProfile::from_mandate(
&card,
&mandate,
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:a2a-steward",
)
.unwrap();
assert!(profile.serve_task("plan", b"a", 30_000).allowed());
// 30k spent of a 50k joule budget; a second 30k task exceeds it.
assert!(!profile.serve_task("plan", b"b", 30_000).allowed());
}
#[test]
fn from_mandate_refuses_currency_denomination() {
let card = ingest_agent_card(&sample_card()).unwrap();
let usd =
ingest_ap2_mandate(&json!({ "payee": "planner.example", "currency": "USD", "maxAmount": 100 }))
.unwrap().accepted_as_verified("test-wallet").unwrap();
let r = A2aProfile::from_mandate(
&card,
&usd,
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:a2a-steward",
);
assert!(r.is_err());
}
#[test]
fn parses_a2a_message_send() {
let req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": { "role": "user", "parts": [{ "kind": "text", "text": "summarize this" }] },
"metadata": { "skillId": "summarize" }
}
});
let t = parse_message_send(&req).unwrap();
assert_eq!(t.skill_id, "summarize");
assert_eq!(t.payload, "summarize this");
assert!(parse_message_send(&json!({ "method": "tasks/get" })).is_err());
}
#[test]
fn sealed_task_receipt_verifies() {
let card = ingest_agent_card(&sample_card()).unwrap();
let mut profile = A2aProfile::new(
&card,
1_000_000,
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:a2a-steward",
)
.unwrap();
let g = profile.serve_task("plan", b"goal", 10_000);
assert!(g.allowed());
// The sealed receipt verifies under the steward key — a third party
// trusts the decision, not the gateway.
let core = jcp_receipt::extract(&g.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
assert_eq!(core.verdict, "allow");
assert_eq!(core.cost_uj, 10_000);
}
/// A **USD** AP2 mandate — the form the payments ecosystem actually emits —
/// carries no joule ceiling (no rate is invented), but it DOES bound money.
fn usd_mandate() -> Ap2Mandate {
ingest_ap2_mandate(&json!({
"paymentMandate": { "payee": "planner.example", "currency": "USD", "maxAmount": 500 }
}))
.unwrap().accepted_as_verified("test-wallet").unwrap()
}
#[test]
fn usd_mandate_yields_a_money_ceiling_not_a_joule_one() {
let m = usd_mandate();
assert_eq!(m.joule_budget(), None); // no exchange rate invented
assert_eq!(m.funds_ceiling(), Some(Funds::new("USD", 500)));
}
/// The whole point of the dual-ceiling design: a real USD mandate is now
/// gateable. The operator supplies the joule ceiling (the mandate has no
/// authority over energy); the mandate supplies the money ceiling; each
/// binds on its own terms.
#[test]
fn usd_mandate_gates_on_money_while_joules_have_room() {
let card = ingest_agent_card(&sample_card()).unwrap();
let mut profile = A2aProfile::from_mandate_priced(
&card,
&usd_mandate(),
10_000_000, // operator's generous joule ceiling
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:a2a-steward",
)
.unwrap();
// First task: $3.00 of the $5.00 mandate, trivial joules → allowed.
let g1 = profile.serve_task_priced("plan", b"goal", 1_000, &Price::new("USD", 300));
assert!(g1.allowed());
assert_eq!(profile.spent_minor("plan"), 300);
// Second task: another $3.00 would exceed the $5.00 ceiling — denied on
// MONEY, though joules had ample room. The receipt records exactly that.
let g2 = profile.serve_task_priced("plan", b"goal", 1_000, &Price::new("USD", 300));
assert!(!g2.allowed());
let core = jcp_receipt::extract(&g2.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
assert_eq!(core.verdict, "deny");
assert_eq!(core.deny_reason.as_deref(), Some("funds-exceeded"));
// The denied task debited neither dimension.
assert_eq!(profile.spent_minor("plan"), 300);
}
}