Agent-to-Agent (A2A)
A2A — one agent transacting with another — is the same primitive as human-to-agent. Same
DelegationCert, same ProofBundle, same verifier algorithm. The only thing that changes is
who’s at the root of the chain.
Human-to-agent Agent-to-agent ────────────── ──────────────
Alice signs Alice signs │ │ ▼ ▼ Cert: Alice → Agent-A Cert: Alice → Agent-A │ │ │ │ then Agent-A signs: │ │ │ ▼ │ Cert: Agent-A → Agent-B │ │ ▼ ▼ Agent-A presents Agent-B presents bundle bundle with with TWO certs in chain ONE cert in chain [Alice→A, A→B] │ │ ▼ ▼ Verifier accepts Verifier walks the chain and intersects scopesThree patterns
Section titled “Three patterns” Pattern 1: Sub-delegation Pattern 2: Mutual auth ───────────────────────── ─────────────────────
Agent-A hires Agent-B for a Two agents need to specific scoped task. Agent-B transact with each other. acts under Agent-A's authority, Both present bundles to transitively under Alice's. the other; both verify.
Pattern 3: Signed receipts ─────────────────────────
Either: a verifier emits a VerificationReceipt to its audit log; or N parties sign an atomic TransactionReceipt envelope. Both are stable in alpha.7 and byte-for-byte interoperable.Pattern 1: Sub-delegation
Section titled “Pattern 1: Sub-delegation”The classic case. Alice’s calendaring agent (Agent-A) needs to hire a travel-booking agent (Agent-B) to handle the actual reservation. Alice doesn’t know Agent-B at all — she trusts Agent-A to scope it appropriately.
For Agent-A to be allowed to sub-delegate at all, Alice must grant the
identity:delegate privilege on her cert to Agent-A. Without it, the verifier rejects
A→B with identity_status: delegation_not_authorized. (See Scopes for
the full rule.)
Alice │ │ scope: [payments:send, identity:delegate] │ — identity:delegate is what lets Agent-A sub-delegate at all │ ▼ Agent-A (calendaring) │ │ scope: [payments:send] │ — Agent-A grants a subset to Agent-B; identity:delegate │ is NOT passed on, so Agent-B cannot further sub-delegate. │ ▼ Agent-B (travel booking) │ │ Agent-B presents a bundle to the airline API: │ delegations: [A→B, Alice→A] (leaf-first per SPEC §6.5) │ challenge_sig signed by Agent-B's hybrid key │ ▼ Airline API ✓ Both hybrid signatures (Ed25519 + ML-DSA-65) verify on every cert ✓ Chain well-formed (A→B.issuer == Alice→A.subject) ✓ Effective scope = lex-sorted intersection = [payments:send] ✓ identity:delegate present in A's grant from Alice ✓ ✓ No cert expired, none revoked, challenge fresh → identity_status: authorized_agentIssuing the sub-delegation
Section titled “Issuing the sub-delegation”// Alice's cert to Agent-A MUST include identity:delegate for A to sub-delegate.// (omitted here — assume aliceToA was issued with scope:// ["payments:send", ratify.ScopeIdentityDelegate])
// Agent-A (calendaring) sub-delegates to Agent-B (travel booking)subCert := ratify.DelegationCert{ CertID: "cert-a-to-b", Version: ratify.ProtocolVersion, IssuerID: agentA.ID, IssuerPubKey: agentA.PublicKey, SubjectID: agentB.ID, SubjectPubKey: agentB.PublicKey, Scope: []string{"payments:send"}, // identity:delegate intentionally NOT passed on IssuedAt: time.Now().Unix(), ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), // shorter than parent}ratify.IssueDelegation(&subCert, agentAPriv)
// Agent-B builds a bundle. Chain order is leaf-first: [A→B, Alice→A].bundle := ratify.ProofBundle{ AgentID: agentB.ID, AgentPubKey: agentB.PublicKey, Delegations: []ratify.DelegationCert{subCert, aliceToA}, Challenge: challengeFromVerifier, ChallengeAt: time.Now().Unix(), ChallengeSig: signedByAgentB,}// aliceToA was previously issued with scope:// [SCOPE_PAYMENTS_SEND, SCOPE_IDENTITY_DELEGATE]
const subCert: DelegationCert = { cert_id: "cert-a-to-b", version: PROTOCOL_VERSION, issuer_id: agentA.id, issuer_pub_key: agentA.public_key, subject_id: agentB.id, subject_pub_key: agentB.public_key, scope: [SCOPE_PAYMENTS_SEND], // identity:delegate NOT passed on constraints: [], issued_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 24 * 3600, signature: { ed25519: new Uint8Array(), ml_dsa_65: new Uint8Array() },};issueDelegation(subCert, agentAPrivateKey);
// Chain order: [leaf, root]. A→B is the leaf.const bundle: ProofBundle = { agent_id: agentB.id, agent_pub_key: agentB.public_key, delegations: [subCert, aliceToA], challenge: challengeFromVerifier, challenge_at: challengeAt, challenge_sig: signChallenge(challenge, challengeAt, agentBPriv), session_context: new Uint8Array(), stream_id: new Uint8Array(), stream_seq: 0,};# alice_to_a was previously issued with scope:# [SCOPE_PAYMENTS_SEND, SCOPE_IDENTITY_DELEGATE]
sub_cert = DelegationCert( cert_id="cert-a-to-b", version=PROTOCOL_VERSION, issuer_id=agent_a.id, issuer_pub_key=agent_a.public_key, subject_id=agent_b.id, subject_pub_key=agent_b.public_key, scope=[SCOPE_PAYMENTS_SEND], # identity:delegate NOT passed on issued_at=int(time.time()), expires_at=int(time.time()) + 24 * 3600, signature=None, # populated in place by issue_delegation)issue_delegation(sub_cert, agent_a_priv)
# Chain order: [leaf, root]. A→B is the leaf.bundle = ProofBundle( agent_id=agent_b.id, agent_pub_key=agent_b.public_key, delegations=[sub_cert, alice_to_a], challenge=challenge_from_verifier, challenge_at=challenge_at, challenge_sig=sign_challenge(challenge_from_verifier, challenge_at, agent_b_priv),)// alice_to_a was previously issued with scope:// vec![SCOPE_PAYMENTS_SEND.into(), SCOPE_IDENTITY_DELEGATE.into()]
let mut sub_cert = DelegationCert { cert_id: "cert-a-to-b".into(), version: PROTOCOL_VERSION, issuer_id: agent_a.id.clone(), issuer_pub_key: agent_a.public_key.clone(), subject_id: agent_b.id.clone(), subject_pub_key: agent_b.public_key.clone(), scope: vec![SCOPE_PAYMENTS_SEND.into()], // identity:delegate NOT passed on constraints: Vec::new(), issued_at: now, expires_at: now + 24 * 3600, signature: HybridSignature { ed25519: Vec::new(), ml_dsa_65: Vec::new() },};issue_delegation(&mut sub_cert, &agent_a_priv);
// Chain order: [leaf, root]. A→B is the leaf.let bundle = ProofBundle { agent_id: agent_b.id.clone(), agent_pub_key: agent_b.public_key.clone(), delegations: vec![sub_cert, alice_to_a], challenge: challenge_from_verifier, challenge_at: now, challenge_sig: sign_challenge(&challenge_from_verifier, now, &agent_b_priv), session_context: Vec::new(), stream_id: Vec::new(), stream_seq: 0,};Verifier behavior with a chain
Section titled “Verifier behavior with a chain”The chain order on the wire is [leaf, …, root] per SPEC §6.5. The verifier walks
root-to-leaf internally:
For chain [A→B, Alice→A] (leaf first, then root):
1. Both hybrid signatures on Alice→A verify with Alice's pubkey 2. Both hybrid signatures on A→B verify with Agent-A's pubkey (whose identity is established by Alice→A.subject_pub_key) 3. Alice→A.subject_id == A→B.issuer_id (chain well-formed) 4. Alice→A's scope contains identity:delegate (else delegation_not_authorized) 5. Both certs are within their issued_at / expires_at windows 6. Challenge signature verifies with Agent-B's pubkey (whose identity is established by A→B.subject_pub_key) 7. Effective scope = scope[Alice→A] ∩ scope[A→B], lex-sorted = [payments:send, identity:delegate] ∩ [payments:send] = [payments:send] 8. required_scope ("payments:send") ∈ effective scope ✓ 9. No constraints denied 10. No cert in chain is revoked
→ identity_status: authorized_agentThe intersection is strict: Agent-B never gets more than Agent-A was given, which is never
more than Alice gave. This is the structural invariant. Notice identity:delegate does not
appear in the effective scope — A intentionally chose not to grant it on, so B cannot
sub-delegate further.
Pattern 2: Mutual authentication
Section titled “Pattern 2: Mutual authentication”Two agents need to trust each other. Each presents a bundle; each runs the verifier on the other’s bundle.
┌───────────────────────────────────────────────┐ │ │ │ Agent-A Agent-B │ │ │ │ ───── here's my bundle ─────▶ │ │ ◀──── verify_bundle(bundle_a) ── │ │ ◀──── here's my bundle ───── │ │ ──── verify_bundle(bundle_b) ─▶ │ │ │ │ Both ✓ → trust established │ │ Either ✗ → abort, log, no transaction │ │ │ └───────────────────────────────────────────────┘The challenge in each direction can use a fresh nonce per side so neither agent can replay the other’s bundle. A typical handshake:
1. Agent-A → Agent-B: nonce_a (32 random bytes) 2. Agent-B → Agent-A: nonce_b, bundle_b (Agent-B signed nonce_a in challenge_sig) 3. Agent-A: verify_bundle(bundle_b, challenge_was: nonce_a) → must pass 4. Agent-A → Agent-B: bundle_a (Agent-A signed nonce_b in challenge_sig) 5. Agent-B: verify_bundle(bundle_a, challenge_was: nonce_b) → must pass 6. Both trust each other; proceedThis is two challenge-response rounds, one per direction. Both agents prove they hold the live key for their respective identities.
Pattern 3: Signed receipts
Section titled “Pattern 3: Signed receipts”Two stable in alpha.7 receipt primitives ship in every SDK. They are interoperable byte for byte across Go, TypeScript, Python, and Rust:
| Primitive | What it attests | Who signs |
|---|---|---|
VerificationReceipt | ”I, this verifier, saw this exact bundle and decided <status> at time T.” | The verifier |
TransactionReceipt | ”These N parties all atomically committed to these terms.” | Every party, all over the same canonical signable |
VerificationReceipt is the audit-trail primitive for a one-sided event (“Agent-B accepted
Agent-A’s proof”). TransactionReceipt is the atomic-commit primitive for an N-party deal —
no partial-valid state, alter one party and every other party’s signature breaks.
VerificationReceipt — “I verified this bundle”
Section titled “VerificationReceipt — “I verified this bundle””VerificationReceipt is single-verifier; Agent-B signs an attestation that captures the
bundle hash, the decision, and chains to the previous receipt by prev_hash so a verifier’s
log is tamper-evident.
// Agent-B, having Verify-ed Agent-A's bundle, issues an attestation:receipt, err := ratify.IssueVerificationReceipt( bundle, // what was verified result, // the VerifyResult prevReceiptHash, // chain to previous; 32 zero bytes for genesis time.Now().Unix(), agentBPriv, // the verifier's hybrid private key)// receipt is byte-for-byte canonicalized; any auditor with agent_b.public_key// can VerifyVerificationReceipt(receipt) without trusting the verifier operator.The wire shape is fixed (see types.go → VerificationReceipt):
{ "version": 1, "verifier_id": "<Agent-B's derived ID>", "verifier_pub": { "ed25519": "...", "ml_dsa_65": "..." }, "bundle_hash": "<32-byte SHA-256 of canonical bundle bytes>", "decision": "authorized_agent", "human_id": "<Alice's ID>", "agent_id": "<Agent-A's ID>", "granted_scope": ["payments:send"], "verified_at": 1799999999, "prev_hash": "<32 bytes; zeros for genesis>", "signature": { "ed25519": "...", "ml_dsa_65": "..." }}Auditors call VerifyVerificationReceipt(receipt) (or per-SDK equivalent) to re-check the
signature against verifier_pub.
TransactionReceipt — N-party atomic commit
Section titled “TransactionReceipt — N-party atomic commit”TransactionReceipt is the multi-party envelope. Every party presents a ProofBundle AND
signs the same canonical envelope. Alter any party’s agent_id, role, or pub key — every
other party’s signature becomes invalid.
// Both parties have agreed to terms; both produce signatures.// terms_canonical_json is the application's own schema — Ratify doesn't interpret it.receipt := ratify.TransactionReceipt{ Version: ratify.ProtocolVersion, TransactionID: "tx-abc-123", CreatedAt: time.Now().Unix(), TermsSchemaURI: "https://example.com/schemas/booking/v1.json", TermsCanonicalJSON: termsBytes, Parties: []ratify.ReceiptParty{ {PartyID: "p1", Role: "buyer", AgentID: agentA.ID, AgentPubKey: agentA.PublicKey, ProofBundle: bundleA}, {PartyID: "p2", Role: "seller", AgentID: agentB.ID, AgentPubKey: agentB.PublicKey, ProofBundle: bundleB}, }, PartySignatures: []ratify.ReceiptPartySignature{ {PartyID: "p1", Signature: sigByAgentA}, {PartyID: "p2", Signature: sigByAgentB}, },}
// Any auditor — including each party — runs the canonical envelope check:res := ratify.VerifyTransactionReceipt(&receipt, ratify.VerifyReceiptOptions{ PartyVerifyOptions: func(role string) ratify.VerifyOptions { switch role { case "buyer": return ratify.VerifyOptions{RequiredScope: "payments:send"} case "seller": return ratify.VerifyOptions{RequiredScope: "transact:sell"} } return ratify.VerifyOptions{} },})// res.Valid is true iff:// - every party's ProofBundle independently verifies under that role's options// - every listed party has exactly one signature// - every signature verifies against that party's agent_pub_key over the canonical envelopeFailure modes are atomic: any per-party failure → res.Valid == false with ErrorReason
pinpointing the party that failed (party_bundle_invalid, party_signature_invalid,
duplicate_party_signature, missing_party_signature, etc.).
When to use which
Section titled “When to use which”- Audit logs / receipt streams →
VerificationReceipt. Cheap, single-signer,prev_hashchain detects backdating. - N-party atomic commerce (escrow, swap, multi-sig action) →
TransactionReceipt. The envelope’s whole point is that you cannot tamper with one party’s role without invalidating every other signature.
Both are byte-for-byte interoperable — a Go-issued receipt verifies in Python, TypeScript, and Rust without rounding error.
Common pitfalls
Section titled “Common pitfalls”| Pitfall | Why it’s wrong | Fix |
|---|---|---|
| Sub-delegating a wider scope than received | Verifier rejects via intersection; chain produces empty effective scope | Sub-delegate a subset |
| Sub-delegation expiry longer than parent | Verifier rejects: child cert is expired-by-parent at the parent’s expires_at | Set child expiry ≤ parent expiry |
| Forgetting to include the parent cert in the bundle | Verifier can’t find Agent-A’s pubkey to verify A→B’s signature; bad_signature | Include every cert in the chain |
| Reusing a challenge from a previous handshake | Verifier rejects: stale challenge_at. Mutual auth must use fresh nonces per direction. | Generate fresh nonces |
Source
Section titled “Source”Chain semantics: SPEC.md §6 (Delegation chain).
Receipts: types.go → VerificationReceipt and TransactionReceipt; receipt_verify.go
→ VerifyTransactionReceipt. Every primitive on this page is stable in 1.0.0-alpha.7 and
byte-for-byte interoperable across the four reference SDKs.
Where to next
Section titled “Where to next”- MCP guide — A2A in the specific context of Model Context Protocol
- Delegate, Present, Verify — chain verification details
- Scopes — what scope intersection looks like