Skip to content

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 scopes
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.

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_agent
// 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,
}

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_agent

The 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.

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; proceed

This is two challenge-response rounds, one per direction. Both agents prove they hold the live key for their respective identities.

Two stable in alpha.7 receipt primitives ship in every SDK. They are interoperable byte for byte across Go, TypeScript, Python, and Rust:

PrimitiveWhat it attestsWho 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.goVerificationReceipt):

{
"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 envelope

Failure 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.).

  • Audit logs / receipt streamsVerificationReceipt. Cheap, single-signer, prev_hash chain 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.

PitfallWhy it’s wrongFix
Sub-delegating a wider scope than receivedVerifier rejects via intersection; chain produces empty effective scopeSub-delegate a subset
Sub-delegation expiry longer than parentVerifier rejects: child cert is expired-by-parent at the parent’s expires_atSet child expiry ≤ parent expiry
Forgetting to include the parent cert in the bundleVerifier can’t find Agent-A’s pubkey to verify A→B’s signature; bad_signatureInclude every cert in the chain
Reusing a challenge from a previous handshakeVerifier rejects: stale challenge_at. Mutual auth must use fresh nonces per direction.Generate fresh nonces

Chain semantics: SPEC.md §6 (Delegation chain). Receipts: types.goVerificationReceipt and TransactionReceipt; receipt_verify.goVerifyTransactionReceipt. Every primitive on this page is stable in 1.0.0-alpha.7 and byte-for-byte interoperable across the four reference SDKs.