Superseded. The current version is v0.6. Rendered from /spec/v0.2.md.
MA2H — Multi-agent to Human Protocol
Version: 0.2 (Draft) · Date: 2026-06-03 · License: Apache-2.0 · Steward: Autonomy
Superseded by v0.3 (2026-06-12, in turn superseded by v0.5) — implement against v0.5. Supersedes v0.1. v0.2 is a breaking hardening pass — see CHANGELOG.
0. Abstract
MA2H 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). MA2H 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 MA2H 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/taskmessages. - 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 MA2H 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:
- 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. - 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. - On resolution the Hub POSTs the Response (§6), including
stateverbatim, tocallback.url. - The re-invoked process MUST verify the Response signature (§9.2) and reject replays, MUST verify the
integrity of
stateit 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. - 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
stateor 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
deliverednotify (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 |
|---|---|---|---|
ma2h_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
{
"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:
{ "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:
"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-ma2h-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=confirmis sugar: whenoptionsis omitted the Hub MUST synthesize exactly two options with valuesapproveanddeny;response.valueis one of those. Ifoptionsis supplied it MUST have exactly two entries.permissions→ lifecycle mapping (§7):allow_respond:falseremoves the answer affordance (the human may onlydecline);allow_ignore:falseremoves the decline/ignore affordance. The v0.1ignoredresolution does not exist — a human ignoring an ask resolves it asdeclined(§6, §7).
5.3 task
A manual action a human performs in the world. Adds an action block:
"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.
{
"ma2h_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)
}
resolutionvalue space (the single canonical list; identical in §1, §7, the schema, and §11):ask→answered|declined|cancelled|expiredtask→completed|dismissed|expired
response.valueis a discriminated union keyed on the originalrequest.mode(string for select/confirm, object for input). It is absent fortask.responsepresence: REQUIRED forask; fortaskpresent only to carry a humancommentand/or finalcheckliststate (nevervalue); never present fornotify(no Response is emitted).- An expiry-defaulted Response MUST set
resolution: "expired",defaulted: true, andresponse.actor: "system:default_on_expire"— never a humanactor. Agents SHOULD fail-closed onexpiredfor high-impact decisions. - The agent MUST treat
(in_reply_to, resolution_id)as the at-most-once key andstateas 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) beatsdefault_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_atlikewise loses todefault_on_expire— evaluated against the same clock as expiry-vs-answer — so an overdueaskresolves toexpired, nevercancelled. Acancelledmessage still emits a Response so push/pull agents get closure.cancelledis 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_expireMUST validate against the request: a member ofoptions[].valueforselect, an object validating againstschemaforinput, ornull. (Forselectthis is schema-enforceable; forinputand for the cross-fieldoptionsmembership, 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/ma2h
Returns a capability document (schema schema/v0.2/capability.schema.json) so an agent can configure
itself without out-of-band knowledge:
{
"ma2h_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):
{ "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:
"callback": {
"mode": "push", // push | pull (pull = omit callback or mode:pull; agent polls 8.2)
"url": "https://agent.example/ma2h/resolve",
"auth": { "scheme": "hmac", "secret_ref": "env:MA2H_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 whoseagent.iddoes not match the credential (403).run_idis 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 itsid— cancel is state-terminating, so an unbound cancel lets one agent withdraw another's openask. To that principal the message SHOULD be indistinguishable from an unknown id (404over403), so the binding also serves as an id-enumeration guard. - Resolver authz.
actorMUST 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 optionalallowed_resolverslists who may resolve it. When absent, the default is fail-closed: only the submittingagent.id's own identity may resolve. (A fail-open default would ship the unauthenticated-resolver hole as opt-in.)client_refMUST 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 = {
"ma2h_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:
MA2H-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.idsecret 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 instate(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
stateblob, 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
bodyin 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-fetchcontext.file.uriunless the URI passes §9.4 host controls. - Telemetry. Hub logs SHOULD exclude
body,context,response.value, andresponse.commentby default, and MUST NOT logstate. A property markedx-ma2h-sensitive: true(or a message-levelsensitive: 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
ma2h_versionisMAJOR.MINOR. The major is the integer before the first dot. A Hub MUST reject a message whose major it does not recognize with400 version_not_supported. Within a recognized major, a Hub SHOULD accept any minor and MUST ignore unknown fields (robustness principle). The schema accepts any0.xviapattern: "^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 MA2H reuses)
| From | Reused in MA2H |
|---|---|
| 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):
- 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.
- Prose audits (human sign-off during spec review, not executable) — the "spec contains a MUST" checks. Verifies text presence, not implementation conformance.
- 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_atbeats default).
Appendix A — Worked Examples
See /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.