# A2H — Agent-to-Human Protocol

**Version:** 0.2 (Draft) · **Date:** 2026-06-03 · **License:** Apache-2.0 · **Steward:** Autonomy

> Supersedes [v0.1](v0.1.md). v0.2 is a breaking hardening pass — see [CHANGELOG](../CHANGELOG.md).

## 0. Abstract

A2H is a vendor- and runtime-neutral protocol by which **agents reach humans** and **receive decisions
back**. Heterogeneous **Agents** POST one of three message types — `notify`, `ask`, `task` — to a central
**Hub**; a **Human** triages them from one inbox; and answers to `ask`/`task` messages are routed back to
the originating (often ephemeral) agent via **push** (webhook) or **pull** (polling). A2H is the
agent↔human complement to A2A (agent↔agent) and MCP (agent↔tools).

This version (0.2) closes the trust-model, return-leg, concurrency, and durability gaps found in the v0.1
design review. It specifies candidate controls for the security- and concurrency-critical requirements;
final closure of those is proven against a conformant Hub (see §12).

## 1. Conformance and Terminology

The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, **MAY** are to be
interpreted as described in RFC 2119 / RFC 8174.

- **Agent** — a software process (any runtime) that originates A2H messages. A "spoke."
- **Hub** — the central server that ingests messages, presents the human inbox, routes responses, and
  performs the security and lifecycle duties in this spec. The "center."
- **Human** — an authenticated operator who triages the inbox and resolves `ask`/`task` messages.
- **Caller** — the specific agent invocation that produced a message, identified by the tuple
  `(agent.id, agent.run_id)`. Responses route to the Caller.
- **Resolution** — the terminal outcome of an `ask`/`task`, carried in the Response (§6).

Three words that v0.1 overloaded are kept distinct in v0.2 and MUST NOT be conflated:

| Term | Meaning |
|------|---------|
| **`status`** | The lifecycle phase of a message (§7): `open`, `delivered`, or a terminal value. The wire field on the submit ack (§8.1) and GET body (§8.2). |
| **`resolution`** | The *terminal* `status` of an `ask`/`task`, surfaced in the Response (§6). Its value space is a subset of `status`. |
| **`state`** | The opaque, agent-owned blob round-tripped verbatim (§4, §6, §9). Never a lifecycle concept. |

A **conformant Hub** MUST accept the message envelope (§4–§5), expose the transport bindings (§8), enforce
the security model (§9), apply the lifecycle rules (§7), and emit the Response (§6) on resolution. A
**conformant Agent** MUST send a valid envelope, MUST be able to receive a resolution by at least one of
push or pull, and MUST honor the agent-side duties marked in §6, §8, and §9 (dedup, signature
verification, state-integrity verification).

## 2. Goals and Non-Goals

**Goals**
- Runtime-agnostic: anything that speaks HTTPS + JSON can participate. No SDK required.
- Minimal core: three verbs, one envelope.
- First-class **ephemeral** agents (a CLI run, a GitHub Action) via pull and the resume pattern (§2.1).
- Async by default; a human is never assumed online.
- Server-authoritative trust: the Hub decides; the agent never trusts the return leg without verifying it.

**Non-Goals (v0.2)**
- The human-facing inbox **UI** (only the wire contract is normative).
- Human **identity / SSO** mechanics. v0.2 requires the Hub to authenticate the operator and attest
  `actor`, but does not specify the authentication method.
- Multi-human **assignment, escalation, SLA** routing. (v0.3.)
- **Streaming** partial agent output; multi-turn **threaded** asks. (v0.3+.)
- **Channel fan-out** (Slack/email/SMS delivery of an inbox item).

### 2.1 Ephemeral Agent Resume Pattern (normative)

The defining A2H flow: an agent that **exits** after asking, and a **new** process that resumes when the
human answers. This is how headless agents (GitHub Actions, CLI runs) participate without holding a
process open.

```
  Run #1 (exits)                  Hub                    Human         Run #2 (re-invoked)
  ─────────────                   ───                    ─────         ──────────────────
  1. seal state  ── POST ask ───▶ ingest, validate
     (encrypt-then-MAC,            store, 202 {id,...}
      key NOT in state)  ◀──────── 202 ack
  2. exit (nothing held open)
                                   present to inbox ───▶ resolve
                                   atomic terminal CAS
                                   stamp resolution_id
                                   sign Response ──────────────────────▶ 3. POST signed Response
                                                                            (push) to re-invoke URL
                                                                         4. verify signature + replay jti
                                                                         5. verify+open state MAC
                                                                            (untrusted until verified)
                                                                         6. dedup (in_reply_to,
                                                                            resolution_id); act once
```

Normative requirements:

1. Before exiting, an agent that needs to resume MUST populate `state` (§4) with enough context to
   reconstruct itself in a new process, and MUST integrity-protect it per §9.3.
2. The agent MUST set `callback.url` (§5.2) to an endpoint that **re-invokes** the agent process (e.g., a
   workflow-dispatch URL), not merely a data sink. The callback host MUST satisfy §9.4.
3. On resolution the Hub POSTs the Response (§6), including `state` verbatim, to `callback.url`.
4. The re-invoked process MUST verify the Response signature (§9.2) and reject replays, MUST verify the
   integrity of `state` it applied (§9.3) and treat it as untrusted until it does, and MUST deduplicate on
   `(in_reply_to, resolution_id)` (§6, §7) and act at most once.
5. The state-seal key MUST be sourced per §9.3 — pre-positioned in the agent runtime, distinct from the
   callback credential, and never carried in `state` or through the Hub.

`actor` MAY be `agent:<agent.id>` — an agent may resolve an ask on behalf of its operator (autonomous
approval). Any action a human resolver can take, an authorized agent resolver can take (§6, §9.1).

## 3. Architecture

```
  ┌─────────┐   POST /v1/messages   ┌──────────────────┐   inbox    ┌────────┐
  │  Agent  │ ────────────────────▶ │       Hub        │ ─────────▶ │ Human  │
  │ (spoke) │ ◀──────────────────── │ ingest · atomic  │ ◀───────── │        │
  └─────────┘  signed push  /  or   │ lifecycle · sign │  resolve   └────────┘
       │       agent polls poll_url │ · SSRF-guard     │  (attested actor,
       └── GET /v1/messages/{id}    └──────────────────┘   authz checked)
```

Trust boundaries: the **request leg** is authenticated by per-`agent.id` credentials (§9.1); the **return
leg** is integrity-protected by a Hub signature the agent verifies (§9.2); **`state`** is opaque to the
Hub and integrity-sealed by the agent (§9.3) — the Hub is never trusted with state integrity.

### 3.1 Durability and Conformance

A conformant Hub MUST durably persist message lifecycle such that a Hub process restart does not lose:
- any **open** `ask`/`task`,
- any **committed resolution**,
- any **`delivered` notify** (it MUST remain pull-checkable per §8.2),
- any **pending push-delivery obligation** (it MUST be re-attempted after restart while non-terminal).

The guarantee is stated as an observable outcome; the storage mechanism is implementation-defined.
In-memory-only storage that loses the above on restart is **non-conformant**.

## 4. Message Envelope (common fields)

Every message is a JSON object. The schema is `schema/v0.2/message.schema.json`.

| Field | Type | Req | Notes |
|-------|------|-----|-------|
| `a2h_version` | string | MUST | `"0.2"`. Version handling per §10. |
| `type` | enum | MUST | `notify` \| `ask` \| `task`. Discriminator. |
| `created_at` | string | MUST | RFC 3339 timestamp (agent clock; informational — the Hub clock is authoritative for expiry, §9.5). |
| `agent` | object | MUST | Origin descriptor (§4.1). |
| `title` | string | MUST | One line for the inbox list. ≤ 200 chars. |
| `body` | string | SHOULD | Human-readable detail. Markdown. Treated as untrusted (§9.6). |
| `priority` | enum | SHOULD | `low` \| `normal` \| `high` \| `urgent`. Default `normal`. |
| `tags` | string[] | MAY | Free-form filter labels. |
| `context` | Part[] | MAY | Attachments (§4.2). |
| `state` | object | MAY | **Opaque, agent-owned** resume blob. The Hub MUST store and return it verbatim in the Response and MUST NOT inspect or log it (§9.3). Required in practice for the resume pattern (§2.1). |
| `client_ref` | string | MAY | Opaque, agent-chosen correlation **label**. Never used for dedup. Not exposed to resolvers (§9.1). |
| `idempotency_key` | string | see §8.1 | Dedup key. **REQUIRED for `ask`/`task`**, MAY for `notify`. Scope and semantics in §8.1. |
| `expires_at` | string | MAY | RFC 3339. Auto-resolution boundary for `ask`/`task` (§7). MUST be in the future at submit or the Hub rejects with 422 (§8.5). |

The `id` is **not** an agent input — the Hub assigns it (§8.1, KTD: Hub-canonical ids).

Cross-type fields (`request`, `action`) are constrained by the discriminated `oneOf` in the schema: a
`notify` MUST NOT carry `request` or `action`; an `ask` carries `request` and MUST NOT carry `action`; a
`task` carries `action` and MUST NOT carry `request`.

### 4.1 `agent` descriptor

```json
{
  "id": "deploybot/dev-team",        // stable logical agent identity (credential is scoped to this)
  "run_id": "run_01H...",         // this specific invocation; (id, run_id) = the Caller
  "runtime": "github-actions",    // github-actions | cli | cloud | desktop | openclaw | other
  "project": "web-app",         // optional grouping key (≈ A2A contextId)
  "labels": { "model": "opus-4.8" } // optional free-form string map
}
```

`run_id` is opaque to the Hub and MUST NOT be used to authorize cross-run access (§9.1).

### 4.2 `Part` (convention borrowed from A2A)

A `context` entry is exactly one of:

```json
{ "kind": "text", "text": "..." }
{ "kind": "data", "data": { "...": "..." } }
{ "kind": "file", "file": { "uri": "https://...", "name": "diff.patch", "mime_type": "text/x-diff" } }
```

Each Part MAY carry `metadata`. A Hub MUST NOT server-side-fetch a `file.uri` unless it passes the same
host controls as a callback URL (§9.4).

## 5. The Three Verbs

### 5.1 `notify`

Informational. No response is expected. Used for summaries, status updates, the daily digest. Carries only
the common envelope (§4). A Hub MUST NOT route a Response for a `notify`.

`notify` is **fire-and-forget but not droppable**: a `notify` reaches `delivered` on acceptance (the 202
ack carries `status: delivered`; there is no observable `created` state and no undeliverable state
machine). A `delivered` notify MUST be durable (§3.1) and pull-checkable via §8.2 for the retention TTL,
so a sender (e.g., a daily-digest agent) can confirm it landed.

### 5.2 `ask`

A decision the human must make. Adds a `request` block:

```json
"request": {
  "mode": "select",            // select | input | confirm
  "options": [                 // REQUIRED when mode=select
    { "value": "ship",  "label": "Ship to prod now",  "description": "Deploy immediately." },
    { "value": "hold",  "label": "Hold for review",   "description": "Wait for a human PR review." }
  ],
  "schema": {                  // REQUIRED when mode=input — FLAT JSON Schema (string/number/bool/enum)
    "type": "object",
    "properties": { "reason": { "type": "string", "x-a2h-sensitive": false } },
    "required": ["reason"]
  },
  "permissions": {             // which affordances/terminals the human is offered (§7)
    "allow_accept": true, "allow_edit": true, "allow_respond": true, "allow_ignore": true
  },
  "default_on_expire": "hold", // applied on expiry; MUST be a member of options[].value (select) or
                               // null, or an object validating against `schema` (input). See §7.
  "allowed_resolvers": ["human:alice"],  // optional authz; default fail-closed (§9.1)
  "callback": { /* §8.3 */ }
}
```

- `mode=confirm` is sugar: when `options` is omitted the Hub MUST synthesize exactly two options with
  values `approve` and `deny`; `response.value` is one of those. If `options` is supplied it MUST have
  exactly two entries.
- `permissions` → lifecycle mapping (§7): `allow_respond:false` removes the answer affordance (the human
  may only `decline`); `allow_ignore:false` removes the decline/ignore affordance. The v0.1 `ignored`
  resolution does **not** exist — a human ignoring an ask resolves it as `declined` (§6, §7).

### 5.3 `task`

A manual action a human performs in the world. Adds an `action` block:

```json
"action": {
  "instructions": "Rotate API_SIGNING_KEY in the prod vault and confirm the webhook still signs.",
  "checklist": [
    { "text": "Generate a new key in the secret manager", "done": false },
    { "text": "Update prod secret", "done": false }
  ],
  "verification": "Webhook test event returns 200 with a valid signature.",
  "allowed_resolvers": ["human:alice"],
  "callback": { /* §8.3 — optional; notify the agent when marked done */ }
}
```

A `task` has no `default_on_expire`: on expiry it resolves `expired` with no default applied (§7).

## 6. Response Envelope (Hub → agent)

Emitted by the Hub when an `ask`/`task` reaches a terminal state. Delivered via §8. The schema is
`schema/v0.2/response.schema.json`.

```json
{
  "a2h_version": "0.2",
  "in_reply_to": "msg_01H...",          // the original Hub-assigned message id
  "resolution_id": "res_01H...",        // Hub-assigned, opaque, random; identical on every channel/retry
  "agent": { "id": "deploybot/dev-team", "run_id": "run_01H..." },  // routing only (responseAgent: id+run_id)
  "resolution": "answered",             // §7 terminal value for the verb
  "response": {                         // present for ask; for task only when a comment/checklist is captured
    "value": "hold",                    // ask only: chosen option value, or the input object (union by mode)
    "edited": false,
    "actor": "human:alice",          // Hub-attested; ^(human|agent|system):.+$ (§9.1)
    "resolved_at": "2026-06-04T18:04:11Z",
    "comment": "Let's get a human eye on the migration first."
  },
  "defaulted": false,                   // true iff resolution came from default_on_expire (actor=system:...)
  "state": { "...": "..." }             // opaque agent blob, round-tripped verbatim (§9.3)
}
```

- **`resolution` value space** (the single canonical list; identical in §1, §7, the schema, and §11):
  - `ask` → `answered` | `declined` | `cancelled` | `expired`
  - `task` → `completed` | `dismissed` | `expired`
- `response.value` is a discriminated union keyed on the original `request.mode` (string for
  select/confirm, object for input). It is absent for `task`.
- `response` presence: REQUIRED for `ask`; for `task` present only to carry a human `comment` and/or final
  `checklist` state (never `value`); never present for `notify` (no Response is emitted).
- An **expiry-defaulted** Response MUST set `resolution: "expired"`, `defaulted: true`, and
  `response.actor: "system:default_on_expire"` — never a human `actor`. Agents SHOULD fail-closed on
  `expired` for high-impact decisions.
- The agent MUST treat `(in_reply_to, resolution_id)` as the at-most-once key and `state` as untrusted
  until verified (§9.3).

## 7. Lifecycle / State Machines

```
notify:   delivered (terminal on acceptance; durable, §3.1)

ask:      open ──┬─▶ answered    ← human answered (allow_respond)
                 ├─▶ declined    ← human refused / ignored (allow_ignore maps here)
                 ├─▶ cancelled   ← agent or Hub withdrew (ask only; §8.4)
                 └─▶ expired     ← expires_at passed; default_on_expire applied if set

task:     open ──┬─▶ completed   ← human marked done
                 ├─▶ dismissed   ← human declined to act
                 └─▶ expired     ← expires_at passed (no default)
```

Normative rules:

- **Atomic single-writer.** Every transition is a single atomic compare-and-set against the current
  status. The **first** transition to a terminal status wins and is **immutable**; later resolve/expiry/
  cancel attempts are no-ops that return the existing Response.
- **Expiry vs answer.** A human resolution accepted **at or before** `expires_at` (Hub clock, §9.5) beats
  `default_on_expire`. Expiry and answer are evaluated against the same clock in the one atomic transition.
- **Resolve vs cancel.** First terminal wins. A cancel arriving after a terminal status is a no-op that
  returns the existing terminal Response (not a fake success), so the agent learns it lost the race. A
  cancel arriving strictly **after** `expires_at` likewise loses to `default_on_expire` — evaluated against
  the same clock as expiry-vs-answer — so an overdue `ask` resolves to `expired`, never `cancelled`. A
  `cancelled` message still emits a Response so push/pull agents get closure. `cancelled` is **ask-only**.
- **At-most-once delivery semantics.** Once a message is terminal, the Hub MUST NOT deliver a *different*
  resolution. In-flight delivery attempts for the terminal resolution MAY complete; they carry the same
  `resolution_id`, and the agent deduplicates (§6).
- `default_on_expire` MUST validate against the request: a member of `options[].value` for `select`, an
  object validating against `schema` for `input`, or `null`. (For `select` this is schema-enforceable; for
  `input` and for the cross-field `options` membership, the Hub enforces it and a conformance vector
  covers it — see §12.)

## 8. Transport Bindings (HTTP/JSON)

All endpoints are HTTPS, `Content-Type: application/json`, base path `/v1`. Plaintext MUST NOT be offered.

### 8.0 Discovery — `GET /.well-known/a2h`

Returns a capability document (schema `schema/v0.2/capability.schema.json`) so an agent can configure
itself without out-of-band knowledge:

```json
{
  "a2h_version": "0.2",
  "max_body_bytes": 65536,
  "max_part_bytes": 262144,
  "max_context_parts": 16,
  "auth_schemes": ["bearer", "apikey"],
  "callback_auth_schemes": ["hmac", "bearer", "apikey"],
  "signature_algs": ["hmac-sha256", "ed25519"],
  "rate_limit": { "requests_per_minute": 60, "inbox_depth": 1000 },
  "retention_days": 30,
  "replay_window_seconds": 120
}
```

### 8.1 Submit — `POST /v1/messages`

Body: a message envelope (§4–§5). On success the Hub returns **`202 Accepted`** (schema
`schema/v0.2/submit-ack.schema.json`):

```json
{ "id": "msg_01H...", "status": "open", "poll_url": "https://hub.example/v1/messages/msg_01H...",
  "review_url": "https://hub.example/inbox/msg_01H..." }
```

For `notify` the ack carries `status: "delivered"`.

**`idempotency_key`** (REQUIRED for `ask`/`task`). The Hub deduplicates on `(agent.id, idempotency_key)`,
**run-independent**, for at least a stated TTL (RECOMMENDED ≥ 24h). A replay with the **same** key and an
identical payload MUST return the original ack including the original `id` and current `status` (so a
crashed agent recovers its outcome). A replay with the same key but a **differing** payload MUST be
rejected with `409` (§8.5). The agent retry recipe (lost ack): retry with the **same** `idempotency_key`
until a 202 or 409 is observed — this is what prevents a duplicate human decision when the 202 is lost and
the agent (holding no `id` yet) must blindly retry.

### 8.2 Pull — `GET /v1/messages/{id}`

Returns the message plus its current `status` and, once terminal, the embedded `response` (schema
`schema/v0.2/get-message.schema.json`). Reads are idempotent: a terminal message returns the same body on
every GET. The Hub SHOULD support long-poll via `?wait=<seconds>` (server-max, cap 60): hold until
resolution or timeout, then return current state (a `200` with `status: open` on timeout is normal, not an
error). A `?since=<RFC3339>` cursor MAY be used; the Hub SHOULD return `304` when status is unchanged.

A resolved message MUST remain pull-available for the retention TTL (RECOMMENDED 30 days; advertised in
§8.0). A **deleted** message returns `410 Gone`; an **unknown** id returns `404`. This is the source of
truth for ephemeral agents and the fallback when push fails (§8.3).

### 8.3 Push — agent-supplied `callback`

An `ask`/`task` MAY include a callback so the Hub delivers the Response actively:

```json
"callback": {
  "mode": "push",                       // push | pull (pull = omit callback or mode:pull; agent polls 8.2)
  "url": "https://agent.example/a2h/resolve",
  "auth": { "scheme": "hmac", "secret_ref": "env:A2H_CALLBACK_SECRET" }  // see §9.2
}
```

Omitting `callback` (or `mode:pull`) means the agent is in pull mode and MUST use §8.2. On resolution the
Hub POSTs the Response (§6) to `url`, **signed** per §9.2. Delivery is **at-least-once**: the Hub MUST
retry on `5xx`/network errors with exponential backoff (≥ 5 attempts), MUST NOT retry on `4xx`, and MUST
cap total attempts and total duration (advertised). The agent's callback receiver MUST deduplicate on
`(in_reply_to, resolution_id)` and act at most once. After retry exhaustion the resolution MUST remain
pull-available (§8.2) — it is never lost. Push is best-effort; pull is the source of truth, and on any
push/pull disagreement the GET value is authoritative.

### 8.4 Cancel — `POST /v1/messages/{id}/cancel`

Withdraws an open `ask`. **Only the submitting principal may cancel** (§9.1); a cancel from any other
authenticated agent is refused as if the id were unknown (`404`), never applied. On success returns
`200 { "id", "status": "cancelled" }` and emits the terminal `cancelled` Response, delivered like a resolve
(push and/or embedded for pull) so the agent gets closure. If the message already reached a **different**
terminal (e.g. `answered`), returns `409` with the existing `{ "id", "status", "resolution" }` (the agent
reads the real outcome). Idempotent: re-cancelling an already-`cancelled` message returns the same `200`.
Tasks are not cancellable in v0.2.

### 8.5 Error Model

Errors use a canonical envelope: `{ "error": { "code": "<machine_code>", "message": "<human>" } }`.

| Status | `code` | When |
|--------|--------|------|
| 400 | `validation_error` / `version_not_supported` | malformed envelope; unknown major (§10) |
| 401 | `unauthenticated` | missing/invalid agent credential |
| 403 | `agent_id_mismatch` / `not_authorized` | credential ↔ `agent.id` mismatch; resolver not in `allowed_resolvers` |
| 404 | `not_found` | unknown message id |
| 409 | `idempotency_conflict` / `already_terminal` | replay with differing payload; cancel/resolve after terminal |
| 410 | `gone` | message deleted (vs 404 unknown) |
| 422 | `invalid_field` | e.g., `expires_at` in the past; `default_on_expire` ∉ options |
| 429 | `rate_limited` | over a published limit; includes `Retry-After` |

Callback (Hub→agent) delivery follows the same 4xx-no-retry / 5xx-retry rule (§8.3).

### 8.6 Rate Limits and Quotas

A Hub MAY enforce, and advertises (§8.0): a per-`agent.id` submit rate and a max inbox depth (over-limit →
`429` + `Retry-After`), a max envelope and per-Part byte size (over-limit → `422`), and a cap on total
callback attempts and duration. These bound both the inbox denial-of-attention and the callback-amplifier
risks. Agents MUST respect `Retry-After` and MUST NOT retry immediately on `429`.

## 9. Security and Privacy

### 9.1 Authentication and authorization

- **Agent → Hub.** Bearer token or API key scoped to one `agent.id`. The Hub MUST reject an envelope whose
  `agent.id` does not match the credential (`403`). `run_id` is opaque and MUST NOT be used to authorize
  cross-run access; the Hub MUST bind a message's poll (§8.2), callback (§8.3), **and cancel (§8.4)** access
  to the principal that submitted it. A non-submitting principal MUST NOT poll, receive the callback for, or
  cancel a message by learning or guessing its `id` — cancel is state-terminating, so an unbound cancel lets
  one agent withdraw another's open `ask`. To that principal the message SHOULD be indistinguishable from an
  unknown id (`404` over `403`), so the binding also serves as an id-enumeration guard.
- **Resolver authz.** `actor` MUST be **Hub-attested** — derived from the Hub's authenticated operator
  session, never from the resolving request body; format `<type>:<id>`, `type ∈ {human, agent, system}`.
  A message's optional `allowed_resolvers` lists who may resolve it. **When absent, the default is
  fail-closed**: only the submitting `agent.id`'s own identity may resolve. (A fail-open default would
  ship the unauthenticated-resolver hole as opt-in.) `client_ref` MUST NOT be exposed to resolvers.

### 9.2 Response integrity (all callback schemes)

The Hub MUST sign every pushed Response, for **every** `callback.auth.scheme` (not only hmac). The
signature is **detached** and computed over a canonical `signed_context`:

```
signed_context = {
  "a2h_version", "callback_url", "id", "in_reply_to",
  "jti", "resolution", "resolution_id", "resolved_at", "t"
}
```

serialized with **RFC 8785 JSON Canonicalization Scheme (JCS)** (deterministic key order, number/Unicode
normalization), then signed with `hmac-sha256` (secret from `secret_ref`) or `ed25519` (advertised in
§8.0). Wire header:

```
A2H-Signature: t=<unix>,jti=<nonce>,v1=<base64url(signature)>
```

The agent MUST verify the signature against the canonical `signed_context` it reconstructs, MUST reject a
`t` outside ±120s of its own clock (replay window), and MUST maintain a **replay cache** of seen `jti`
values with a TTL **≥ the replay window** (this is independent of the 30-day message retention; a stable
pull response is not protected by `jti` and is instead trusted via §8.2 transport + the immutable terminal
record). Binding the signature to `id` + `resolution_id` + `callback_url` prevents cross-message and
cross-endpoint replay. The body-signing key MAY be the same as the callback-auth credential or distinct;
either way rotation MUST be supported (the agent MAY hold two valid keys during overlap).

### 9.3 State integrity (agent-owned) and key provenance

Returned `state` is **UNTRUSTED input**. An agent MUST NOT act on `state` contents without verifying
integrity it applied itself. The Hub storing `state` opaquely and never inspecting it (§4) is necessary
but not sufficient — it does not stop a malicious Hub or resolver from tampering with the blob.

- An agent SHOULD AEAD-seal `state` (encrypt-then-MAC) and MUST verify the MAC before use; a minimal HMAC
  is permitted for low-sensitivity state.
- **Key provenance (REQUIRED).** The state-seal key MUST be a per-`agent.id` secret pre-positioned in the
  agent runtime (e.g., a CI/Actions secret, a vault env var), **distinct from the callback credential**,
  surviving re-invocation independently of any Hub-held value. The key MUST NOT be embedded in `state`
  (circular — zero integrity) and MUST NOT transit the Hub. If this key co-resides with a compromised
  callback host, the guarantee is void.
- **Anti-pattern (do not do this):** placing the seal key inside the `state` blob, or deriving it from
  data the Hub can see. Either makes "verify before use" pass on attacker-controlled state.

### 9.4 Callback destination safety (SSRF)

Before attaching a referenced credential to a callback, the Hub MUST verify the `callback.url` host is
agent-owned (verification method implementation-defined: DNS-TXT, HTTP well-known, or operator approval).
The Hub MUST refuse private/link-local/metadata IP ranges, MUST apply that refusal **at delivery time**
(not only at registration — DNS-rebinding defense), MUST NOT follow redirects, and MUST only attach a
credential when the URL host matches a registered binding. A dev-mode relaxation MAY exist but MUST
fail closed in production. (v0.1's GitHub-PAT-to-`/dispatches` example is a confused-deputy anti-pattern;
see `examples/callback-anti-pattern.md`.)

### 9.5 Clock authority

The **Hub clock** is authoritative for `expires_at` evaluation and for the §9.2 signature `t` window
(the agent applies the window against its own clock for the inbound signature; the Hub uses its own clock
for expiry). Agents SHOULD synchronize via NTP.

### 9.6 Content safety and telemetry minimization

- A Hub MUST NOT render raw HTML from `body` in the inbox (Markdown is sanitized to a no-raw-HTML profile;
  external links visibly marked, not auto-followed; remote images not auto-fetched). It MUST NOT
  server-side-fetch `context.file.uri` unless the URI passes §9.4 host controls.
- **Telemetry.** Hub logs SHOULD exclude `body`, `context`, `response.value`, and `response.comment` by
  default, and MUST NOT log `state`. A property marked `x-a2h-sensitive: true` (or a message-level
  `sensitive: true`) strengthens the SHOULDs to MUST and excludes the value from any export/telemetry. A
  sensitive value MAY be carried as a reference (`{ "secret_ref": "vault://..." }`) so the raw secret
  never enters the agent's LLM context (the CHEQ idea).

## 10. Versioning and Roadmap

- `a2h_version` is `MAJOR.MINOR`. The **major** is the integer before the first dot. A Hub MUST reject a
  message whose **major** it does not recognize with `400 version_not_supported`. Within a recognized
  major, a Hub SHOULD accept any minor and MUST **ignore unknown fields** (robustness principle). The
  schema accepts any `0.x` via `pattern: "^0\\.\\d+$"`.
- Spec text Apache-2.0; contributions under DCO. Breaking changes bump the version.

**Roadmap (out of v0.2):** human SSO mechanics; assignment/escalation/SLA (v0.3); multi-turn threads;
streaming (AG-UI interop); channel fan-out.

## 11. Provenance (what A2H reuses)

| From | Reused in A2H |
|------|---------------|
| **A2A** (Linux Foundation) | `Part` union; `callback` ← `PushNotificationConfig` auth schemes; `project` ≈ `contextId`; terminal-state irreversibility; `input-required` → the `ask` lifecycle; capability discovery ≈ Agent Card (§8.0). |
| **HITL Protocol** | `202` + `poll_url`/`review_url` submit handshake (§8.1). |
| **MCP elicitation** | `select` options as enum+label; flat input schema; accept/decline/cancel-shaped resolutions. |
| **HumanLayer** | opaque `state` round-trip; typed contact channels (roadmap). |
| **LangGraph** | `request.permissions` ← `HumanInterruptConfig`. |
| **CHEQ** (IETF draft) | keep human-entered secrets out of the agent's LLM context (§9.6). |
| **RFC 8785 (JCS)** | canonical serialization for the detached Response signature (§9.2). |

## 12. Validation and Proof Obligations

Conformance vectors **cannot** prove the security/concurrency requirements — a JSON Schema checks shape,
not cryptographic soundness or race-freedom. Vectors are labeled by class (see `conformance/README.md`):

1. **Schema-validation vectors** (executable now, no Hub) — each example validates, or is an intentional
   negative vector. Covers the envelope, return leg, resolution mapping, per-scheme auth conditionals,
   cross-type rejection.
2. **Prose audits** (human sign-off during spec review, not executable) — the "spec contains a MUST"
   checks. Verifies text presence, not implementation conformance.
3. **Downstream proof obligations** (validatable only against a conformant Hub) — the security/concurrency
   controls. This spec **specifies candidate controls** for resume-hijack (§9.3), SSRF (§9.4), Response
   integrity (§9.2), and concurrency (§7); **closure is proven against the Hub**, which MUST discharge:
   the §9.2 signature test vector (known input → known signature), a replay-cache bypass test, an
   SSRF-allowlist + DNS-rebinding bypass test, and a CAS-race test (two terminal transitions in a sub-ms
   window → first wins, one `resolution_id`; answer-at-`expires_at` beats default).

## Appendix A — Worked Examples

See [`/examples`](../examples): `notify-daily-digest`, `ask-dev-team-decision`, `ask-mode-input`,
`ask-mode-confirm`, `ask-sensitive-field`, `task-manual-action`, and the Responses
`response` (answered), `response-expired-default`, `response-cancelled`, `response-declined`,
`task-completion-response`, plus the safe `callback-agent-resume` and the `callback-anti-pattern` note.
