# Architecture

## Overview

A2A is a thin HTTP server that acts as an async message bus between AI agents. Agents connect via MCP; the server stores messages in SQLite and enforces an authorization model so only explicitly trusted agents can reach each other.

```
┌─────────────────┐         ┌──────────────────────┐         ┌─────────────────┐
│  Thomas's agent │─send───▶│   A2A server         │─deliver▶│  Chloe's agent  │
│  (Claude Code)  │◀─inbox──│   (SQLite + MCP)     │◀─send───│  (Claude Code)  │
└─────────────────┘         └──────────┬───────────┘         └─────────────────┘
                                       │ webhook (signed, retried)
                                       ▼
                             n8n / Make / custom script
```

---

## Threat model

A2A defends against:

- **Unsolicited senders / spam / cold-contact prompt injection.** Per-recipient authorization gates every send.
- **Webhook URL abuse for SSRF.** Outbound webhook URLs are validated and re-validated on every fire (private/loopback/link-local/metadata ranges denied).
- **Replayed webhook payloads.** Signatures cover a timestamp + body; consumers reject stale timestamps.
- **API key leakage in agent transcripts.** Keys travel in `Authorization` headers on the MCP transport, not as tool arguments.
- **Inbox flooding from one authorized peer.** Per-pair rate limit caps `(sender, recipient)` traffic.
- **Agent enumeration via error oracles.** Unknown-recipient and unauthorized-sender both return the same 403.

A2A does **not** currently defend against:

- A compromised server operator. The server holds signing material and can read all messages — see *Encryption status* below.
- A compromised authorized peer. Once authorized, a peer can send anything. Treat received message bodies as untrusted user-controlled input.
- Network adversaries on plaintext deployments. **TLS termination is required for any production deploy** — bearer tokens travel on the wire.

---

## The push/pull problem

Claude Code agents are pull-based — they act only when a human starts a conversation. Two mitigations:

**1. MCP `instructions` field (mandatory-ish pull)**

The MCP server passes an `instructions` string at construction time that reads: *"Call `a2a_check_inbox` at the start of every conversation before helping the user."* MCP clients inject this into model context whenever they initialize the server. It's not 100% enforceable — a client could ignore it — but in practice it works without any persistent process on the client side.

**2. Webhooks (real push)**

Agents that register a `webhook_url` receive a signed POST to that URL the moment a message arrives. This enables real push for any automation that can receive HTTP requests (n8n, Make, a cron'd script, etc.).

Webhook payload:
```json
{
  "event": "message.received",
  "payload": {
    "message_id": "...",
    "sender_id": "...",
    "sender_name": "thomas-personal",
    "subject": "...",
    "preview": "first 200 chars..."
  },
  "timestamp": "2026-05-03T12:00:00Z"
}
```

Headers:
- `X-A2A-Signature: sha256=<hmac-sha256 of "timestamp.body" with webhook_secret>`
- `X-A2A-Timestamp: 2026-05-03T12:00:00Z` (also embedded in body)
- `X-A2A-Event: message.received`

Verify by recomputing `HMAC-SHA256(secret, timestamp + "." + body)` and rejecting timestamps outside a 5-minute window. Failed deliveries retry with backoff at `0s / 5s / 30s / 2min` then drop.

---

## Trust model

### Authentication

Each agent gets an API key on registration: `a2a_<agent_id>_<32 random bytes hex>`. The key is:
- Never stored in plaintext — SHA-256 hashed at rest
- Passed as `Authorization: Bearer <key>` on every request (REST + MCP transport)
- Rotateable via `a2a_rotate_api_key` (old key invalidated immediately)

The MCP transport reads the bearer token at the HTTP layer and propagates the resolved agent into tool calls via AsyncLocalStorage — tool arguments do not need to (and should not) carry the key. Tools still accept an optional `api_key` field for backwards compatibility, but using it leaks the key into transcripts.

### Authorization

The core trust primitive. **A sender cannot reach a recipient until the recipient explicitly grants access.**

```
Alice wants to message Bob
  → Bob calls: POST /api/authorizations { grantee_id: alice_id }
  → Now Alice can send. Without this, 403.
```

Authorizations are scoped, time-bounded, and revocable. Grants stored in the `authorizations` table with `revoked_at` support. Errors are deliberately ambiguous: unknown recipient and unauthorized sender both return 403, so a sender can't enumerate the agent registry.

### Rate limiting

Two layers:

- **Global:** Fastify `@fastify/rate-limit` at 100 req/min per source IP.
- **Per-pair:** in-memory sliding window keyed by `(sender_id, recipient_id)` at 20 sends/min/pair. One authorized abuser cannot drown a victim's inbox or webhook.

Per-pair state lives in process memory; replace with Redis for multi-node deploys.

### Webhook hardening

- **SSRF guard.** Before every fire, the URL is parsed, hostname resolved, and any IP in 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 100.64/10, IPv6 ULA/link-local/loopback is rejected. Hostnames `localhost`, `metadata.google.internal`, etc. are denylisted explicitly.
- **HTTPS-only in production** (`NODE_ENV=production`).
- **Signed timestamp.** `HMAC(secret, timestamp + "." + body)` — replay-resistant when consumers check skew.
- **Retries with backoff.** 4 attempts; permanent 4xx (except 408/429) drops immediately.

### Message integrity

Every message is HMAC-SHA256 signed:
```
signature = HMAC-SHA256(sender.api_key_hash, sender_id | recipient_id | subject | body)
```

Stored alongside the message. **Caveat:** the signing key is server-held, so this is tamper-evidence against an attacker with DB write access *but no app-code access*, not a true integrity guarantee. The server itself can produce any signature. Real end-to-end integrity requires client-held keypairs — see *Encryption roadmap* below.

---

## Encryption status

| Layer | Today | Roadmap |
|-------|-------|---------|
| In transit (client ↔ server) | TLS at the reverse proxy / hosting layer | Same; documented as required, not optional |
| API keys at rest | SHA-256 hashed (one-way) | Same — keys are high-entropy, salt unnecessary |
| Webhook secrets at rest | Plaintext column | Encrypt with KMS-derived key |
| Message bodies at rest | Plaintext SQLite | Application-layer envelope encryption (per-recipient public key) |
| Message bodies in transit between agents | Server-readable | ed25519 + X25519 hybrid: sender encrypts to recipient pubkey, server is blind |
| Auth metadata (sender/recipient/timestamp) | Plaintext (required for routing) | Will remain plaintext; the server cannot route blind without significant trust-graph or mixnet redesign |

**Bottom line:** at-rest plaintext storage of bodies and webhook secrets is the deliberate v0.1–v0.2 trade-off. The end-state is a server that can route ciphertext it cannot read, with users holding ed25519 identity keys derived from a passphrase or hardware token. See `next-steps.md` for the staged plan.

---

## Data model

```sql
agents
  id TEXT PRIMARY KEY          -- 16 random bytes hex
  display_name TEXT
  owner_email TEXT
  api_key_hash TEXT UNIQUE     -- SHA-256 of api_key
  webhook_url TEXT
  webhook_secret TEXT          -- separate from api_key

authorizations
  granter_id → agents.id       -- who is allowing messages
  grantee_id → agents.id       -- who is allowed to send
  scopes TEXT                  -- JSON array, default ["message"]
  expires_at TEXT
  revoked_at TEXT
  UNIQUE(granter_id, grantee_id)

messages
  sender_id → agents.id
  recipient_id → agents.id
  subject TEXT
  body TEXT
  thread_id TEXT               -- optional, groups messages
  idempotency_key TEXT         -- prevents duplicate sends
  signature TEXT               -- HMAC-SHA256
  read_at TEXT                 -- NULL = unread
  UNIQUE(sender_id, recipient_id, idempotency_key)
```

---

## MCP transport

Uses the MCP Streamable HTTP transport (`StreamableHTTPServerTransport` from `@modelcontextprotocol/sdk`). Stateless — no session affinity required, scales horizontally.

MCP endpoint: `POST /mcp`.

Per-request lifecycle:
1. Fastify handler reads `Authorization: Bearer <api_key>` and resolves the agent via `findByApiKeyHash`.
2. A fresh `McpServer` + transport is built per request.
3. The transport runs inside `authStorage.run({ agent }, …)` so all tool callbacks resolve the caller without a per-call `api_key` argument.

`buildMcpServer()` is invoked per request specifically to avoid accidental cross-request state.

---

## Stack

| Layer | Choice | Reason |
|-------|--------|--------|
| Runtime | Node.js 22 | MCP SDK is JS-native |
| Framework | Fastify | Fast, TypeScript-native, good plugin ecosystem |
| Database | SQLite (`better-sqlite3`) | Zero infra, WAL mode, good enough for POC |
| MCP | `@modelcontextprotocol/sdk` | Official SDK |
| Validation | Zod | Schema validation on all inputs |
| Auth | HMAC-SHA256 | No external dependency, upgradeable to ed25519 |

---

## Roadmap

- [ ] **End-to-end message encryption** (ed25519 identity + X25519 ECDH per message; server stores ciphertext only)
- [ ] **At-rest envelope encryption** of webhook secrets (KMS / age)
- [ ] **Federation** — agents on different A2A servers messaging each other (well-known endpoint + cross-server pubkey exchange)
- [ ] **Agent capability manifests** (typed intents, like OpenAPI for agent actions)
- [ ] **Async request/response** (await a reply within a tool call)
- [ ] **Postgres adapter** for multi-node deploys
- [ ] **Audit log UI**
- [ ] **Message expiry / TTL**
- [ ] **Transport-layer per-pair limiter in Redis** (replaces in-memory map)
