Superseded. The current version is v0.6. Rendered from /spec/v0.5.md.
MA2H — Multi-agent to Human Protocol
Version: 0.5 (Draft) · Date: 2026-08-10 · License: Apache-2.0 · Steward: Autonomy
Supersedes v0.4. v0.5 is additive and backward-compatible: it adds a hub-mediated, store-and-forward inter-agent leg — sessions (§16), addressed envelopes (
to, §4), three new mailbox entry kinds (§8.7), and delivery honesty (§14) — alongside the unchanged v0.4 legs. No existing wire format, envelope, lifecycle, or signature changes. Designed via SCP #24. See CHANGELOG and MIGRATION.
0. Abstract
MA2H is a vendor- and runtime-neutral protocol by which agents reach humans and receive decisions
back, by which a human reaches a specific agent, and — as of v0.5 — by which agents of one
account reach each other. 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). The v0.4 inbound leg mirrors that return leg in reverse: a
Hub-attested human authors a directive addressed to one agent, and the agent drains it from a durable
per-agent mailbox by the same pull-first / webhook-optional mechanism (§13). v0.4 also adds two
cross-cutting primitives that enrich both directions: an acknowledgment/receipt (§14) closing the
loop on a delivered message or answer, and a derived presence signal (§15) telling a human whether an
agent is listening. The v0.5 inter-agent leg routes the same three verbs agent→agent through the same
mailbox: a session (§16) makes one live invocation addressable, an optional envelope to (§4)
parks the message in the destination's mailbox as a signed message entry (§8.7), the addressee
resolves it as an attested agent: actor, and the Response routes back to the submitting session as a
response entry. Reachability and delivery honesty (§14, §15) keep the leg truthful: a
destination-reachability snapshot on the submit ack, per-session presence, and terminal
bounced/expired delivery states — the Hub never guarantees an agent is listening; it guarantees
nobody is left believing a lie. MA2H is the agent↔human complement to A2A (agent↔agent, HTTP-addressable
peers) and MCP (agent↔tools); the v0.5 leg covers hub-mediated coordination among one account's
agents — spokes that are HTTP clients with no inbound port — not cross-account federation (§2).
This version (0.5) builds on the v0.4 inbound-leg pass — which itself built on the v0.3 payload-bound
signature and the v0.2 hardening that closed the trust-model, return-leg, concurrency, and durability
gaps found in the v0.1 design review — and adds the inter-agent leg without changing any v0.4
leg. A 0.5 Hub remains backward-compatible with 0.3/0.4 envelopes. The new leg reuses the existing
machinery throughout: the §4 envelope, the §8.7 mailbox (at-least-once, visibility-timeout,
explicit ack), RFC 8785 JCS canonicalization and detached signatures (§9.2/§9.7 → §9.8), jti replay
dedup, the §7 CAS lifecycle, and durability (§3.1) rather than inventing new machinery. As in prior
versions, it specifies candidate controls for the security-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). - Directive — a human→agent message (v0.4, §13): a Hub-attested
human:<id>sends an instruction or FYI addressed to oneagent:<id>, delivered from that agent's mailbox. The inbound mirror ofnotify. - Mailbox — the durable, per-
agent.idqueue of pending directives an agent drains (§8.7, §13). - Acknowledgment (receipt) — a one-shot, terminal confirmation that the receiving party got the other
party's message or answer, optionally with a note (v0.4, §14). Cross-cutting; distinct from a
notify. - Presence — a derived, best-effort signal of whether an
agent:<id>is currently reachable/listening, from its existing poll/subscription activity (v0.4, §15). v0.5 extends it per session (§15). - Session — a v0.5 Hub-registered, lease-bound, ephemeral addressing scope under one
agent.id(§16). The principal (theagent.id+ its credential) authenticates and is accountable; the session locates one live invocation. A session id is an address, not a secret and not a credential. Prior art: XMPP bare-JID vs. full-JID resource binding (RFC 6120), SIP REGISTER contact bindings withExpires(RFC 3261), NATS ephemeral consumers (§11). - Entry — one delivered item in the §8.7 mailbox, paired with its detached signature. v0.4 had one
entry kind (the directive); v0.5 adds three, visible only to session-presenting drains:
message(an addressed §4 envelope, agent-authored),response(a §6 Response delivered to the submitting session), andreceipt(a Hub-originated delivery-status notification to a sender, §14.2). Terminology note: thereceiptentry kind is distinct from the §14 acknowledgment ("receipt") a party posts — an ack says "the counterparty got it"; areceiptentry says "here is what the delivery track did" (v0.5:bounced). §14 keeps the ack; the entry kind is always writtenreceiptin code font.
Three words that v0.1 overloaded are kept distinct in v0.3 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).
The inbound leg (§13) is OPTIONAL to offer and to consume: a conformant Hub MAY omit it entirely (and
then MUST NOT advertise inbound in §8.0), and a conformant Agent need not drain a mailbox. A Hub that
does offer it MUST implement the §13 mailbox semantics, sign every delivery per §9.7, and advertise
inbound (§8.0); an Agent that does consume it MUST verify the §9.7 signature and deduplicate on the
directive id (§13.4). Offering or consuming the inbound leg is independent of full v0.3 conformance —
neither implies the other.
The sessions primitive (§16) and the inter-agent leg (v0.5) are likewise OPTIONAL to offer and to
consume, advertised via sessions and inter_agent (§8.0). A Hub that offers the inter-agent leg MUST
implement sessions, the §8.7 session-scoped drain and entry kinds, the §9.8 entry signatures, submit-time
destination validation (§8.1), and the §14 acknowledgment primitive including the v0.5 terminal
delivery states — the delivery-honesty rules ride the §14.2 track, so advertising inter_agent without
ack is non-conformant (§8.0). The leg is additionally account-opt-in: inter_agent.enabled
defaults false, and a Hub MUST NOT accept to-addressed submits for an account that has not opted in.
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.5)
- The human-facing inbox UI (only the wire contract is normative).
- Human identity / SSO mechanics. MA2H requires the Hub to authenticate the operator and attest
actor/from, but does not specify the authentication method. - Multi-human assignment, escalation, SLA routing. (Post-v0.5.)
- Streaming partial agent output; multi-turn threaded asks. (Post-v0.5. The §8.7 SSE stream binding delivers mailbox entries; it does not stream partial output.)
- Channel fan-out (Slack/email/SMS delivery of an inbox item).
- Human→agent
ask/task. The inbound leg remains deliberately scoped to the one-way directive (thenotifymirror). v0.4 deferred the routed-agent-response mirror of §6 as "a materially larger design (agent-side resolution, an attestedagent:actor on the return leg, a second signed leg)" — v0.5's inter-agent leg builds exactly that leg (§8.7 entry kinds, §9.8), so the future human→agentask/taskSCP shrinks to a routing rule. It is still deferred (§10). - Push-to-wake / scheduling. Delivering a mailbox entry does not start, wake, or guarantee the existence of a consuming agent; the mailbox is durable and drained whenever the agent next polls. How an agent is scheduled to poll (or is re-invoked by the optional webhook) is out of scope. Agent liveness signaling, a v0.4 non-goal, moves in scope in v0.5 (§15, §16): the mailbox still never starts anyone — it now just refuses to lie about whether anyone is listening.
- Cross-account anything. The v0.5 inter-agent leg is hub-mediated coordination among one account's agents — not cross-account federation, not agent discovery, not an open agent mesh. Cross-account addressing is rejected as unknown (§8.1, §9.8).
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),
- (inbound leg, §13) any un-acked directive in a mailbox (it MUST remain drainable until acked or expired), and any pending directive webhook obligation (re-attempted after restart while non-terminal),
- (inter-agent leg, v0.5) any un-acked mailbox entry of any kind (§8.7 —
message,response, andreceiptentries carry the same drainable-until-acked-or-terminal guarantee as directives), any active session lease (§16 — a session reachesclosedorexpiredonly by its two defined transitions; a restart MUST NOT silently forget a live lease, which would fire the §14.2 bounce rules as an undocumented third terminal path), and any pending bounce obligation (the §14.2 transitions and their auto-resolutions applied when a session went terminal; re-applied after restart while incomplete).
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.5/message.schema.json.
| Field | Type | Req | Notes |
|---|---|---|---|
ma2h_version |
string | MUST | "0.5" (a 0.3/0.4 agent still sends its version — accepted, §10). 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). |
to |
string | MAY | v0.5. Destination address: agent:<agent.id> (principal-addressed) or agent:<agent.id>#<session.id> (session-addressed). Absent to = the human inbox, unchanged; present to routes the message to the destination's §8.7 mailbox as a message entry. to: human:... is invalid in 0.5 (multi-human routing remains a non-goal, §2). Grammar and validation below. |
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.
to grammar (v0.5, pinned). The # separator is chosen because : already splits <type>:<id>
and agent ids may contain /. Within to, the first # terminates the agent-id segment; the
session segment MUST match ^sess_. An agent id containing # is therefore inexpressible in to
(its own first # parses as the separator and the remainder fails ^sess_) — and such an id cannot
register sessions either (§16) — so the principal and session forms parse unambiguously.
Submit-time destination validation (MUST, v0.5). The Hub MUST validate to at accept time, within
the submitter's account: unknown agent.id → 422 unknown_destination; session unknown → 422;
session terminal → 410 destination_gone (§8.5). Accepting an unroutable destination and silently
dead-lettering the message is exactly the false-belief failure the v0.5 delivery-honesty rules exist to
kill (§14). The same validation applies retroactively to the §13 directive to (v0.4 left it
unstated; see §13.2). Cross-account addressing MUST be rejected identically to unknown — no existence
oracle. When the submitting agent lacks session/presence visibility for the destination (§15, §16
policy), the terminal-vs-unknown split MUST collapse to 422 unknown_destination for that sender — the
error split must not become a session-state oracle the visibility policy denies.
Sender-side symmetry (MUST). An addressed (or agent.session-bearing) submit from a principal
whose own agent.id contains # MUST be rejected 422 invalid_field: the Hub would have to attest a
from address the first-# grammar cannot represent (§8.7). A #-bearing id therefore cannot
participate in the inter-agent leg on either end — it cannot be addressed, cannot register
sessions (§16.1), and cannot send addressed mail — while its ordinary human-inbox traffic is
unaffected.
An addressed message is accepted with its owning track's status — queued for a notify (never
delivered), open for an ask/task — plus the REQUIRED destination snapshot; its mailbox life
is the §14.2 delivery track (§5.1, §8.1). Routing, entry delivery, and the resolver default for
addressed ask/task are specified in §8.7, §5.2, and §6; the entry signatures in §9.8. The leg is
account-opt-in (§1, §8.0).
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
"session": "sess_01H...", // v0.5, optional: this invocation's registered session (§16)
"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).
session (v0.5, optional). A sess_ id naming one of the authenticated principal's own
registered sessions (§16): a valid-but-foreign-or-unknown session → 422; the caller's
own-but-terminal session → 410 destination_gone (mirroring the §4 rule for to — unless the
terminal was the §16.4 operator kill-switch, in which case the code is session_closed_by_operator,
§16.3). When present,
the Caller's mailbox for Response delivery is that session (§6), and the Hub-attested from on the
message's delivered form is session-qualified (§8.7) — which is also the discovery mechanism: a
session-registered agent participating in the inter-agent leg SHOULD include agent.session on its
submits, so its traffic carries a usable session-qualified address (§16). run_id is unchanged (audit
label; still non-authorizing). agent.session and agent.run_id are excluded from the §8.1
idempotency identical-payload comparison (§8.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 human-inbox (to-absent) 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.
Addressed notify (v0.5). The delivered-on-acceptance rule applies ONLY to the human-inbox path.
An addressed notify is accepted with 202 { "status": "queued" } (§8.1): it is parked in a
mailbox and may still bounce or expire unseen, so an ack asserting delivered
at accept time would itself be the false belief v0.5 bans (§14). An addressed notify's lifecycle IS
the §14.2 delivery track (queued → delivered | bounced | expired); addressed ask/task keep the §7
open → resolution machinery in status, exactly as today (open asserts nothing about delivery and
was never the lie), with the outbound mailbox track carried by the §8.2 mailbox object. On every
addressed ack, of any verb, the addressed marker and misroute detector is the REQUIRED destination
snapshot (§8.1) — never the status value.
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;
// for an ADDRESSED message the default is the addressee, §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.5/response.schema.json.
{
"ma2h_version": "0.3",
"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).
Resolution by an agent (v0.5, addressed messages). The addressee of an addressed ask/task
resolves it through the resolve binding (§8.8) with its agent credential; the Hub attests actor
as the session-qualified agent actor (agent:<id> or agent:<id>#<sess>, per the §8.8 session
presentation). This was already
half-blessed: §2.1 permits actor = agent:<agent.id>. request/action blocks, modes, expires_at,
default_on_expire, idempotency (§8.1 scope unchanged; see §4.1 for the session/run exclusion), cancel
(§8.4, submitter-bound), and the §7 CAS lifecycle apply unchanged. The resolver default for
addressed messages is the addressee (§9.1).
Response delivery to the submitting session (v0.5). When the Caller registered a session
(agent.session, §4.1) and that session is live at resolution time, the Hub additionally delivers
the Response to that session's mailbox as a response entry (§8.7) — the return leg for an
agent-addressed ask/task, and available to any session-registered Caller in place of a webhook:
ephemeral agents get push-grade latency over one outbound connection with zero callback infrastructure
(§9.4's SSRF surface included). If the Caller registered no session, or its session is terminal by
resolution time, delivery falls back to the unchanged v0.4 path — §8.2 pull (authoritative) and any
§8.3 callback — with no bounce emitted (no session ⇒ pure v0.4 return path). Dual-path delivery is
safe: every channel carries the same resolution_id and the agent already dedups on
(in_reply_to, resolution_id). The entry is signed per §9.8.
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.)- Undeliverable auto-resolution (v0.5, addressed messages). An addressed message additionally
carries
the §14.2 delivery track (an addressed
notify's lifecycle IS that track, §5.1). When an addressedask/task's delivery track reaches either undeliverable terminal —bounced(§14.2) orexpired(mailbox retention lapsed while queued: the resolver-facing entry is gone and no addressee can ever see it, so leaving the resolutionopenwould strand the sender forever) — the ask auto-resolvescancelledand the taskdismissed, with attestedresponse.actor: "system:undeliverable"— no new resolution values, and the sender unblocks instead of waiting on a corpse. This transition rides the same atomic first-terminal-wins CAS: it is a harmless no-op returning the existing Response when the addressee already resolved before dying (or when the message-levelexpires_atalready fired the §7 expiry).
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.5/capability.schema.json) so an agent can configure
itself without out-of-band knowledge:
{
"ma2h_version": "0.5",
"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,
"inbound": {
"enabled": true,
"poll_url": "https://hub.example/v1/inbox",
"ack_url": "https://hub.example/v1/inbox/ack",
"max_batch": 32,
"visibility_timeout_seconds": 60,
"retention_days": 30,
"signature_algs": ["hmac-sha256", "ed25519"],
"webhook_supported": true,
"session_param": true,
"stream_url": "https://hub.example/v1/inbox/stream",
"stream_max_hold_seconds": 60
},
"ack": { "enabled": true, "signature_algs": ["hmac-sha256", "ed25519"] },
"presence": { "enabled": true, "freshness_seconds": 90 },
"sessions": {
"enabled": true,
"max_ttl_seconds": 3600,
"max_live_per_agent": 16,
"agent_list_visibility": false,
"terminal_retention_seconds": 86400,
"operator_hard_stop": true
},
"inter_agent": {
"enabled": true,
"entry_kinds": ["message", "response", "receipt"],
"sender_allowlists": true
}
}
The inbound object (v0.4, §13) advertises the human→agent leg. A Hub that does not offer inbound
directives MUST omit inbound (or set inbound.enabled: false); its absence means "no mailbox here," and
a pre-0.4 Hub omits it entirely. The ack object (v0.4, §14) advertises the acknowledgment/receipt
primitive (signature_algs are the algs the Hub uses to sign a pushed ack, §14.4); the presence
object (v0.4, §15) advertises the presence signal (freshness_seconds is the window after which a
last_seen reads offline, §15.2). A Hub offering neither omits them. All fields beyond ma2h_version
remain SHOULD.
v0.5 additions. inbound gains session_param (true iff the drain accepts ?session=, §8.7) and
the OPTIONAL stream binding (stream_url, stream_max_hold_seconds — which MUST be ≤ the presence
freshness window, §8.7). The sessions object (§16) advertises the session primitive: max_ttl_seconds
(the clamp bound), max_live_per_agent (over-cap registration → 429), agent_list_visibility
(the deployment ceiling on whether same-account agents may list/read account sessions: a Hub
MAY narrow it per account beneath the advertised value and MUST NOT widen beyond it, so false
denies every account while true advertises only that an account MAY hold the grant — never that a
given caller does, which is what sessionList.scope answers, §16.4; own-session visibility is
unconditional regardless), and terminal_retention_seconds (how long a terminal session stays
readable). operator_hard_stop (OPTIONAL, default false when absent) advertises the §16.4.1 durable
stop: true means an operator close survives terminal-session purge and gates re-registration until
an authenticated human lifts it. A client MUST NOT infer a hard stop from its absence — absent means
the kill-switch is cooperative, and a supervisor that blanket-restarts will re-register through it. The inter_agent object advertises the inter-agent leg: enabled (default false — the
leg is account-opt-in; enabling it is an explicit account-owner act, and a Hub MUST NOT accept
to-addressed submits for an account that has not opted in), entry_kinds
(["message", "response", "receipt"], §8.7), and sender_allowlists (Hub support for per-destination
sender restriction is REQUIRED of a conformant Hub offering the leg; whether any allowlist is
configured is per-account policy, §13.5). A submit blocked by a destination's allowlist MUST be
rejected 422 unknown_destination, indistinguishable from a nonexistent destination (§8.5) — the
allowlist must not become a sender-visible probe of who may reach whom, exactly the §4 collapse rule
for the visibility policy. inter_agent requires the §14 primitive: the v0.5
delivery-honesty states ride the §14.2 track, so a Hub advertising inter_agent MUST implement ack
(§14) including the v0.5 terminal states — advertising inter_agent without ack is non-conformant.
A 0.5 sender MUST feature-detect via this document before using to, and MUST additionally apply the
§8.1 ack misroute check (capability caching alone is TOCTOU across rolling deploys and rollbacks).
8.1 Submit — POST /v1/messages
Body: a message envelope (§4–§5). On success the Hub returns 202 Accepted (schema
schema/v0.5/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 a human-inbox notify the ack carries status: "delivered". For an addressed (to-carrying)
message the ack carries the owning track's status — "queued" for a notify (§5.1), "open" for an
ask/task (the §7 resolution track, exactly as unaddressed) — plus, for every addressed verb,
the REQUIRED destination snapshot below. The replay rule is thereby unambiguous: a same-key
idempotent replay (below) returns the current status of the verb's owning track — the §7
resolution track for an ask/task (open, then its terminal — a bounce reads cancelled/
dismissed by system:undeliverable, §7), the delivery track for an addressed notify (queued,
then delivered/bounced/expired) — never a mixture, so independent implementations agree on
every replay.
Reachability snapshot on the addressed ack (v0.5, MUST). The ack for an addressed message MUST carry the destination's current §15 reachability, so a sender (and its human) knows at send time whether this will be picked up promptly or is parked in a durable mailbox:
{ "id": "msg_01H...", "status": "queued", "poll_url": "https://hub.example/v1/messages/msg_01H...",
"destination": { "state": "offline", "last_seen": "2026-08-10T11:20:00Z" } }
When the sender lacks visibility for the destination (§16 policy), the object MUST be exactly
{ "state": "unknown" } with no last_seen — still honest ("no promise of prompt pickup"), never an
ungated presence oracle.
The ack is also the version-misroute detector (sender MUST, v0.5). Capability feature-detection is
time-of-check/time-of-use across rolling deploys and rollbacks: a mixed-version endpoint or a cached
capability document can put a to-carrying envelope in front of a pre-0.5 Hub, which ignores the
unknown field (§10 robustness) and misroutes the message to the human inbox. A pre-0.5 Hub cannot
emit the destination object, so: a 0.5 sender MUST treat an addressed-submit ack without
destination as a misroute, MUST surface the failure to its caller, and SHOULD cancel a misrouted
ask via §8.4. (Feature-detection via §8.0 before first use remains required; the ack check closes the
deployment-window gap.)
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. Scope clarification (v0.5, unchanged scope):
agent.session and agent.run_id are excluded from the identical-payload comparison — a
crashed-and-restarted agent retrying with the same key from a new session/run MUST receive the original
ack (the recovery recipe above exists for exactly this), and the original submit's session remains
the bound Caller for Response routing (first submit wins, §6).
8.2 Pull — GET /v1/messages/{id}
Returns the message plus its current status and, once terminal, the embedded response (schema
schema/v0.5/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).
On a Hub that offers the ack primitive (§14), the GET body additionally carries an optional delivery
object — the receipt track (delivered-to-agent → acknowledged) — which the human reads to see whether
the agent got and acknowledged the answer. It is additive and orthogonal to status/response (§14.2).
For an addressed message (v0.5) this same GET — submitter-bound as ever — additionally carries a
mailbox object: the sender's authoritative view of the outbound mailbox track
(queued → delivered → acknowledged, or terminal bounced/expired, §14.2 — did the addressee
see it; on bounced, a present delivered_at means seen-then-orphaned). Receipts (§8.7) are
best-effort; this pull is the source of truth. The two objects stay track-true: delivery is always
the response track (ask/task — did the submitter get the answer), mailbox always the outbound
track (addressed messages only; for an addressed notify it IS the lifecycle and status mirrors
it, §5.1).
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.3.
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 / session_closed_by_operator |
credential ↔ agent.id mismatch; resolver not in allowed_resolvers; v0.5: on a Hub advertising sessions.operator_hard_stop, a session registration by a principal an operator has stopped (§16.4.1) — same code as the 410 because the client's action is identical (stop; do not re-register), different status because no session resource is gone |
| 404 | not_found |
unknown message id |
| 409 | idempotency_conflict / already_terminal / not_acknowledgeable |
replay with differing payload; cancel/resolve after terminal; §14.3 response-leg ack of a non-terminal message or of a response not yet delivered-to-agent (§14.2) |
| 410 | gone / destination_gone / session_closed_by_operator |
message deleted (vs 404 unknown); v0.5: to or agent.session names an own-but-terminal session (§4, §16). session_closed_by_operator is emitted at a caller's own-session touchpoints when the session was closed by the account's human operator (the §16.4 kill-switch; touchpoint split in §16.3) — the client's action is stop: do not auto-re-register; surface to a human |
| 422 | invalid_field / unknown_destination |
e.g., expires_at in the past; default_on_expire ∉ options; v0.5: to names an unknown (or cross-account, indistinguishably) destination, the session-state split is collapsed for a sender without visibility (§4), or the destination's sender allowlist blocks the submitter (§8.0) — a policy block MUST be indistinguishable from a nonexistent destination |
| 429 | rate_limited |
over a published limit; includes Retry-After |
Unknown-code fallback (MUST, v0.5). Within a recognized status class, a client MUST treat an
unrecognized code as the code that touchpoint would return absent the refinement — additive
codes refine a class, never replace it. Where a class has more than one base code, the touchpoint
disambiguates: an unrecognized 410 at an own-session presentation touchpoint reads as gone, at a
destination-addressing touchpoint as destination_gone (§16.3), and an unrecognized 422 as
invalid_field, and so on.
This is fail-open by design: a stop-semantics refinement degrades to the class's base action for
consumers that predate it — exactly as session_closed_by_operator does for pre-marker bridges
(§16.4) — in exchange for never crashing or stalling old consumers on additive codes.
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.
v0.5 notes. Session registration and addressed submits ride the same per-agent.id limits (plus
the sessions.max_live_per_agent cap, §16); mailbox depth caps bound queue abuse for addressed mail as
they do for directives. Inter-agent chatter and the human leg share one per-agent.id budget — a
chatty coordinator can starve its own human-facing asks — so Hubs MAY advertise separate inter-agent
quotas (rate_limit.inter_agent_requests_per_minute, §8.0: a distinct budget for addressed submits;
absent means addressed submits share requests_per_minute). Ask-loops between agents (A asks B asks A)
are bounded by the same quotas; explicit hop-limits
are deliberately deferred (§10 roadmap).
8.7 Inbox — the agent mailbox (v0.4 inbound leg; v0.5 inter-agent leg)
The transport for the human→agent leg (§13) and — as of v0.5 — the inter-agent leg. Available only on a
Hub advertising inbound (§8.0). Both endpoints are authenticated by the agent's existing bearer
token / API key scoped to one agent.id (§9.1); the credential — not any request field — selects the
mailbox, so an agent can only ever drain and ack its own mailbox.
Drain — GET /v1/inbox. Returns the caller's pending directives (§13.1) in FIFO order, each paired
with its detached signature (§9.7):
{ "messages": [ { "directive": { /* §13.1 */ }, "signature": "MA2H-Signature: t=..,jti=..,v1=.." } ] }
?max=<n>caps the batch (Hub-max advertised asinbound.max_batch);?wait=<seconds>long-polls (server-max, cap 60) exactly like §8.2 — hold until a directive is available or timeout, then return (an emptymessageson timeout is normal, not an error).- A returned directive is marked in-flight for the
inbound.visibility_timeout_secondswindow: it is hidden from subsequent drains until either acked (removed) or the window lapses (redelivered — this is the at-least-once guarantee). The Hub MUST re-sign each delivery with a fresht/jti(§9.7). - FIFO is guaranteed on first delivery; redelivery MAY reorder relative to fresh directives, so the agent
MUST NOT depend on strict ordering for correctness — it deduplicates on the directive
id(§13.4).
Consume / ack — POST /v1/inbox/ack. Removes directives the agent has durably processed:
{ "ids": ["dir_01H...", "dir_01H..."] }
Returns 200 { "acked": <count> }. Idempotent: acking an already-acked or unknown id is a no-op (counted
or not, Hub's choice), never an error. Acking is the agent's explicit consume; an un-acked directive is
redelivered after the visibility window and retained until acked or expired (§13.3). Ack is submitter-bound
like drain: an ack referencing an id not in the caller's own mailbox has no effect (and MUST NOT reveal
whether that id exists elsewhere).
Session presentation on the ack (v0.5). The ack MAY present the caller's session —
POST /v1/inbox/ack?session=sess_... — under exactly the §8.7.1 drain rules (own live session, else
foreign/unknown → 404, own-terminal → 410). Session presentation affects lease attribution
only, never ack authorization (which stays credential-scoped): an ack presenting a session is
client-originated activity that renews that session's lease and presence (§15.1, §16.2); an ack
without one renews no session lease. A streaming bridge SHOULD ack with ?session= — during a held
stream (§8.7.2), acks may be its only client-originated traffic, and a session-less ack from a
multi-session principal gives the Hub no honest way to know which session is alive.
Webhook (optional). If the agent has pre-registered a §9.4-verified inbound callback, the Hub MAY also POST each signed directive to it, following the §8.3 at-least-once retry rules (5xx/network retry with backoff, no 4xx retry, capped attempts/duration). Push is best-effort; the mailbox (drain/ack) remains the source of truth and the fallback — a directive delivered only by webhook is still drainable until acked or expired. Registration of the agent's inbound callback URL is out-of-band and implementation-defined (the same host-ownership verification as §9.4).
8.7.1 Session-scoped drain and the v0.5 entry kinds
Drain opt-in (?session=). GET /v1/inbox?session=sess_... returns, in addition to v0.4
directives: session-addressed entries for that session, principal-addressed entries
(first-claim-wins, below), and response/receipt entries for that session. The presented session
MUST be the caller's own: a foreign or unknown session → 404 (indistinguishable, §9.1 stance); the
caller's own-but-terminal session → 410 — distinct by design, so a bridge returning from a lease
lapse learns "re-register and continue" (error.code: "gone") instead of treating its own address as
nonexistent (§16). When the session was closed by the account's operator, the 410 instead carries
session_closed_by_operator — "stop; do not re-register" (§16.3/§16.4). A
session-less drain returns exactly the v0.4 shape — a 0.4 consumer never sees an unknown entry shape,
back-compat by construction. Ack (POST /v1/inbox/ack) is unchanged and covers all entry kinds.
The three v0.5 entry kinds (enumerated in inter_agent.entry_kinds, §8.0; delivered only to
session-presenting drains; each signed per §9.8 and re-signed with a fresh t/jti at every delivery,
exactly as §9.7):
{ "messages": [
{ "directive": { /* §13.1 */ }, "signature": "MA2H-Signature: t=..,jti=..,v1=.." },
{ "message": { /* below */ }, "signature": "MA2H-Signature: t=..,jti=..,v1=.." },
{ "response": { /* §6 */ }, "signature": "MA2H-Signature: t=..,jti=..,v1=.." },
{ "receipt": { /* below */ }, "signature": "MA2H-Signature: t=..,jti=..,v1=.." }
] }
message— an addressed §4 envelope (agent-authorednotify/ask/task) in its delivered form: the submitted envelope plus Hub-assignedidand Hub-attestedfrom(agent:<id>, oragent:<id>#<sess>when the submit carriedagent.session) — never request-supplied, exactly the §13.1 attestation rule. The Hub MUST strip submitter-side machinery before delivery:state(the submitter's opaque resume blob, §9.3 — round-tripped only in the §6 Response),client_ref(§9.1 already forbids exposing it to resolvers), and anyrequest.callback/action.callback(the submitter's delivery config).idempotency_keyis delivered (inert to the addressee). Theagentdescriptor is delivered but advisory (unbound, mirroring §9.2's top-levelagent); the attested identity isfrom. The recipient's duties are §13.4 applied wholesale, plus the v0.5 amendments (§13.4).response— a §6 Response delivered to the submitting session's mailbox (§6). The body is byte-for-byte the §6 shape; the destination is bound in the §9.8 signed context only.receipt— a Hub-originated delivery-status notification to a sender; v0.5 uses it for the §14.2 bounce. Fields:ma2h_version,type: "receipt",id(Hub-assignedrcpt_id — the entry's ack key, below),in_reply_to(the affected entry/message id — withevent, its dedup key),event(bouncedin v0.5),prior(queued|delivered— preserves the never-seen vs. seen-then-orphaned distinction, §14.2),at,session(the terminal destination session), andto(the sender session it is delivered to). Receipts are best-effort, at-most-once-meaningful (the recipient dedups on(in_reply_to, event)), are delivered only to live sessions of the sender, and MUST NOT themselves generate receipts — the sender's §8.2 pull remains authoritative.
Ack keys (pinned per kind). POST /v1/inbox/ack takes an ids array covering all entry kinds; an
entry's ack key is: a directive's id; a message entry's id; a response entry's
resolution_id (the §6 body carries no mailbox id — res_ ids are Hub-assigned, globally unique,
and non-colliding with dir_/msg_ keys); a receipt entry's id (rcpt_). Every ack key is
signature-bound in its kind's context (§9.7/§9.8), so a consumer never acks on an unauthenticated
identifier. The id namespaces are normative for v0.5-minted ids (schema-enforced on ≥ 0.5
payloads): a Hub MUST mint directive
ids in dir_, message ids in msg_, resolution ids in res_, receipt ids in rcpt_ (and session
ids in sess_, §16.1) — disjointness is what keeps the shared ids array unambiguous, not
convention. Ids minted before v0.5 are grandfathered (an upgrade never strands queued mail or breaks
a stored Response); the Hub remains responsible for ack-key uniqueness across every entry it
delivers, whatever their vintage.
What an inbox ack does, per kind (pinned). Acking always consumes the entry (removal + no more redelivery). Its track effect (§14.2/§14.3):
- directive — unchanged v0.4: consuming IS acknowledging (§14.3); the directive track goes
acknowledged(optionalnotecarried). messageentry — the §14.3 fold applies wholesale: the ack advances the addressed message's mailbox track toacknowledged(optionalnote), which is what the sender's authoritative §8.2 view reports.responseentry — the ack is equivalent to a note-lessPOST /v1/messages/{id}/ack(§14.3's response-leg ack; the drainer here is the submitting principal): it advances the ask/task response track toacknowledgedunder the same first-ack-wins rule (§14.1 — whichever ack path lands first wins, the other is the standard no-op). The drain that delivered the entry already stampeddelivered-to-agent(client-originated delivery evidence, §15.1; a stream push did not, §8.7.2).receiptentry — consume only; receipts have no track and generate nothing (§8.7.1). Keys therefore stay non-colliding across kinds by their prefixes — in particular, when an addressed ask bounces, the sender's session can hold the auto-cancellationresponseentry and the bouncereceiptfor the samein_reply_toconcurrently, and their ack keys (res_…,rcpt_…) still address exactly one entry each. Ack semantics are otherwise unchanged (idempotent; foreign/unknown ids are no-ops that reveal nothing, §8.7).
The delivered payloads all validate against schema/v0.5/inbound-message.schema.json; a consuming
agent MUST validate shape before acting (§13.4 — the payload digests bind only content fields, so shape
validation is what rejects unsigned injected fields such as a state blob on a message entry).
Delivery channels for the v0.5 entry kinds. The pre-registered §8.7 webhook remains
directives-only in v0.5: message, response, and receipt entries are delivered by the
session-scoped drain and the OPTIONAL stream (§8.7.2) only. (Webhook delivery for the v0.5 entry kinds
is roadmap, §10 — a session, unlike a principal, is ephemeral, and the §9.4 registration model is
per-principal.)
Routing and claim semantics. An addressed message is enqueued in the destination mailbox rather than presented to the human inbox. Session-addressed entries are visible only to drains presenting that session. Principal-addressed entries are visible to any session-presenting drain by the destination principal — first-claim-wins under the existing visibility-timeout semantics above. Principal addressing is role delivery: a sender that intends a specific invocation SHOULD session-address, and multiple concurrent sessions draining principal-addressed mail is the documented degraded mode of the shared-credential setup (proper setup: per-session self-registration, with tooling auto-registering; a claimant session that dies mid-processing is rescued by ordinary visibility-timeout redelivery to a sibling — which is why the §14.2 bounce rules are scoped to session-addressed mail). Directives likewise MAY be session-addressed (§13.2).
Human visibility. Addressed messages default out of the human triage inbox (they are fleet traffic, not human work) but MUST remain readable/auditable by the account's authenticated human — same posture as §15.3 (the operator owns the account; agent↔agent traffic is not confidential from them). The presentation is product, not protocol.
8.7.2 Stream — OPTIONAL SSE binding (v0.5)
A Hub MAY offer inbound.stream_url (§8.0): the same authenticated, session-scoped entries as the
drain, pushed as Server-Sent Events over a held connection; acks remain explicit POSTs.
- Liveness requires client-originated proof — a merely-open socket is not evidence. SSE is
unidirectional and TCP half-open: the Hub cannot distinguish a live client from a crashed process
behind a NAT whose socket still accepts buffered writes, so an open connection MUST NOT by itself
renew the session lease (§16) or presence (§15). Instead: the Hub MUST bound each stream hold and
close the stream at the bound; the client's reconnect is the renewal (standard SSE
auto-reconnect makes this free for healthy clients), alongside the other client-originated activity
in §16. The bound for a given hold is the lesser of the advertised
inbound.stream_max_hold_seconds(which MUST be ≤ the presence freshness window) and a point sufficiently before the presenting session's lease expiry that a standard immediate reconnect lands within the lease — the margin is implementation-defined but MUST be nonzero: closing exactly at expiry hands the client a renewal moment it can only use after the lease is already immutably terminal, so a session whose TTL is shorter than the advertised hold would still lapse (bouncing its mail, §14.2) under a healthy-but-idle open stream. A Hub MAY additionally (or instead) clamp a requestedttl_secondsup to an advertised minimum compatible with its stream holds (sessions.min_ttl_seconds, §8.0, §16.1). A dead client never reconnects, its lease lapses within one freshness window, and the §14.2 bounce fires — the zombie socket cannot hold a sessiononline; a healthy one is always handed its renewal moment before the lease can lapse. - Stream delivery is provisional. A stream push starts the entry's visibility window exactly like a
drain claim, but only client-originated receipt evidence advances the delivery track (§15): a
drain response (client-requested) stamps
delivered; a server-originated stream push does not — a stream-delivered entry advances on its ack (or a later drain) and reverts to queued-visible after the visibility window otherwise. At-least-once is preserved; a push into a dead socket changes nothing the sender is told. - Long-poll drain remains the conformance floor — a pull-only Hub is conformant; the stream is advertised capability, not a requirement.
8.8 Resolve — POST /v1/messages/{id}/resolve (v0.5)
The wire binding for resolving an open ask/task — the §14.3 resource-scoped sub-action the spec
had so far only named. v0.4 never needed it interoperable: the only resolver was the account's human,
acting through the Hub's own product surface (§2 non-goals), with §2.1's agent-resolver latent. The
inter-agent leg makes it load-bearing — an addressed message's addressee (§6) resolves over the wire —
so v0.5 pins it. (A Hub's human-facing surface MAY use the same binding internally; that remains
product.)
Request body (schema schema/v0.5/resolve-request.schema.json):
{ "resolution": "answered", "value": "approve", "comment": "Restarting now." }
| Field | Type | Req | Notes |
|---|---|---|---|
resolution |
enum | MUST | The terminal the resolver asserts: answered | declined (ask) or completed | dismissed (task). cancelled is §8.4's verb (submitter-bound); expired is Hub-only (§7). |
value |
string | object | see notes | REQUIRED iff resolution: "answered" — the chosen option value (select/confirm, a string member of options[].value) or the input object validating against request.schema (input), per §5.2. MUST NOT appear for any other resolution. |
comment |
string | MAY | Carried into response.comment (§6). |
checklist |
array | MAY | Task resolutions only: the final §5.3 checklist state, carried into the Response. MUST NOT appear for ask resolutions. |
- Authentication & authorization. The caller authenticates per §9.1 (agent credential, or the
Hub's authenticated operator session for a human resolver) and MUST satisfy the message's
allowed_resolvers(§9.1, including the v0.5 addressed-message default and the session-qualified matching rules). An unauthorized or unbound caller gets404/403per §9.1/§8.5. - Session presentation. An agent resolver MAY present its session as
?session=sess_..., exactly the §8.7.1 drain rule (own live session, else foreign/unknown →404, own-terminal →410); the Hub then attests the session-qualifiedactor(§6). Presenting a session is how a resolver satisfies a session-qualifiedallowed_resolversentry (§9.1); absent the parameter the attested actor is the bare principal. - Validation & lifecycle. The Hub validates
resolutionagainst the verb andvalueagainst therequestblock (422 invalid_fieldotherwise), then applies the §7 atomic CAS: the first terminal wins — a resolve arriving after a terminal is a no-op returning409 already_terminalwith the existing{ id, status, resolution }(the caller reads the real outcome, as §8.4). On success:200 { "id", "status": "<resolution>", "resolution_id": "res_..." }, and the Hub emits the §6 Response through every configured channel (push, pull, and — for a live submitting session — the §8.7responseentry).
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. - Resolver default for ADDRESSED messages (v0.5). For a
to-carrying message theallowed_resolversdefault changes from the submitting principal to the addressee: absent list ⇒ only thetoprincipal (any of its sessions) may resolve — still fail-closed, and the submitter of an ask is the one party who must not answer it. The account's human is deliberately NOT a default resolver for agent-addressed messages (list them inallowed_resolversto opt in): the operator's unstick recourse is the §16 session kill-switch — closing a wedged addressee's session bounces its queued mail and auto-resolves its pending asks (§7, §14.2) without the human impersonating an agent decision. - Matching agent resolvers against session-qualified actors (v0.5, pinned). The attested resolving
actorfor an agent is session-qualified when a session was presented (§6), so anallowed_resolversentry matches as follows: a principal-form entryagent:<id>matches that principal acting under any session (the actor's session qualifier is ignored for the comparison); a session-qualified entryagent:<id>#<session>matches only that exact session — which is how a submitter restricts resolution to one specific invocation.human:/system:entries match exactly, as today. The addressee default above behaves like the principal-form entry. - Session authorization (v0.5). Session registration, renewal, close, and visibility rules are in §16; the same submitter-binding stance applies (another principal's session id MUST be indistinguishable from unknown).
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",
"payload_sha256", "resolution", "resolution_id", "resolved_at", "t"
}
serialized with RFC 8785 JSON Canonicalization Scheme (JCS) (deterministic key order, IEEE-754 number
formatting, canonical minimal string escaping — RFC 8785 §3.1 preserves string content and does not
normalize Unicode), 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)>
v1 well-formedness (v0.5). A verifier MUST accept a v1 iff it is base64url
(A–Z a–z 0–9 - _), carries either no padding or exactly the RFC 4648 structural pad count for its
length, and decodes to at least 32 bytes — and MUST NOT impose more (in particular, no canonical
round-trip check: a value that happens to look hex-shaped is valid base64url). One shared rule kills
the two-implementations-drift class where a hand-rolled second validator rejects conformant traffic;
the reference exports it as decodeMac/isWellFormedMac, and dp-025 pins the accept/reject
boundary executably.
payload_sha256 (v0.3) binds the response payload. It is the lowercase-hex SHA-256 of the JCS
serialization of the fixed-key object { "response": <the Response responseobject, ornull>, "state": <the Response state, or null> }. This binds the entire response detail the agent consumes —
value, comment, actor, edited, resolved_at — and the round-tripped state, so altering any of
them in transit invalidates the signature. The Hub MUST compute payload_sha256 over the payload it
delivers; the agent MUST recompute it from the payload it actually received — never trusting a
transmitted digest — and include it in the signed_context it reconstructs before verifying. Numeric
payload values MUST be canonicalized per RFC 8785 §3.2.2.3 so a conformant signer and verifier agree
byte-for-byte. RFC 8785 serializes a number as its IEEE-754 double value (the Number::toString form), so
ordinary decimals (0.1, 1e-7, -0.001) and in-range integers all canonicalize deterministically;
implementations MUST use a conformant JCS implementation (a JS JSON.stringify of a number already
conforms — a non-JS implementation MUST use a vetted JCS library, and MUST preserve string content as-is:
RFC 8785 §3.1 does not normalize Unicode). The one caveat is exactness, not conformance: because a
JSON number is a double, an application that must preserve an integer beyond ±(2^53−1) exactly MUST carry
it as a string (a bare number would silently round). The dp-004 conformance vector pins the canonical
bytes + digest for a numeric payload as the cross-language interop target. payload_sha256 is carried only
inside the signed_context covered by the MA2H-Signature
header; it is intentionally not a field of the Response body schema (a body-borne digest would be
attacker-suppliable, defeating the recompute requirement above).
resolution, resolution_id, and resolved_at are additionally bound as top-level fields. The Response's
top-level defaulted flag and its routing-only top-level agent object (agent.id / agent.run_id) are
not bound and MUST be treated as advisory: a conformant agent MUST derive the human-vs-expiry-default
distinction from the bound actor (system:default_on_expire vs a human:/agent: identity) and
resolution — never from defaulted — and MUST NOT use the top-level agent (e.g. agent.id) for
authorization, correlation, or state-key selection (the bound in_reply_to + callback_url are the
authenticated routing identity). Before v0.3 the answer value for a
select/input ask was unsigned — a terminating proxy could flip it and verification still passed.
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; payload_sha256 binds the decision payload itself. 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).
9.7 Directive integrity (inbound leg, v0.4)
The inbound mirror of §9.2. A directive drives agent behavior, so the agent MUST authenticate its origin
and integrity before acting — a forged or tampered directive is a prompt-/command-injection vector. The Hub
MUST sign every directive delivery (drain and webhook alike), for every advertised
inbound.signature_algs scheme, with a detached signature over a canonical inbound_signed_context:
inbound_signed_context = { "from", "id", "jti", "ma2h_version", "payload_sha256", "t", "to" }
serialized with RFC 8785 JCS (§9.2) and signed with hmac-sha256 or ed25519. The wire header reuses
the §9.2 form: MA2H-Signature: t=<unix>,jti=<nonce>,v1=<base64url(signature)>.
payload_sha256is the lowercase-hex SHA-256 of the JCS serialization of the fixed-key object{ "directive": <the human-authored content object> }, where the content object carries exactly the directive's presenttitle,body,priority,tags, andcontextfields. This binds the instruction the agent consumes, so altering (or adding/stripping) any of it in transit invalidates the signature. Numbers canonicalize per RFC 8785 §3.2.2.3 exactly as in §9.2. As in §9.2,payload_sha256is carried only inside the signed context (never as a body field), and the agent MUST recompute it from the directive it actually received rather than trusting any transmitted digest.from,id, andtoare bound as top-level fields:fromauthenticates the attested author,idis the stable at-most-once key, andtobinds the directive to its addressed agent — so a directive signed foragent:Xcannot be re-addressed toagent:Ywithout breaking the signature (and the receiving agent additionally confirmstois itself, §13.4).- Unbound metadata (advisory).
created_at,expires_at, andsensitiveare not bound — neither top-level nor insidepayload_sha256(which covers only thetitle/body/priority/tags/contextcontent). They are Hub-authoritative advisory fields and MUST NOT drive an agent's security decisions: the Hub — not the agent — enforcesexpires_at(it drops an expired directive before delivery, §13.3), andsensitivegoverns Hub-side telemetry (§9.6). An agent MUST NOT, for example, extend trust based on a receivedexpires_atorcreated_at. (This mirrors §9.2 treating the top-leveldefaulted/agentas advisory.) - Per-delivery signing (REQUIRED). Unlike a §6 Response (delivered promptly on resolution), a directive
MAY rest in a mailbox for a long time before the agent polls. The Hub MUST therefore compute a fresh
tandjtiat each delivery, so a legitimately-old directive still lands inside the agent's replay window. Onlyt/jtichange per delivery; the boundfrom/id/to/payload_sha256are stable.
The agent MUST verify the signature against the inbound_signed_context it reconstructs, MUST reject a t
outside its replay window (±replay_window_seconds, §8.0) against its own clock, MUST maintain a jti
replay cache (TTL ≥ the window, §9.2), and MUST deduplicate acting on the directive id (§13.4). Because
every directive delivery is signature-verified on both channels — the pull mailbox is an unsolicited
inbound command surface, not a response the agent solicited — this is a MUST on pull as well as webhook,
which is intentionally stronger than the §8.2 "authenticated GET is trusted transport" stance for the
return leg. The signing key MAY be the same as, or distinct from, the §9.2 body-signing key; rotation MUST
be supported (the agent MAY hold two valid keys during overlap).
9.8 Inter-agent entry integrity (v0.5)
The v0.5 entry kinds (§8.7) are delivered from the same unsolicited mailbox surface as directives, so
each carries a detached signature the consumer MUST verify before acting — the §9.7 discipline applied
to three new contexts. Everything structural is inherited: RFC 8785 JCS serialization of the canonical
context (§9.2), the MA2H-Signature: t=<unix>,jti=<nonce>,v1=<base64url(signature)> wire header,
hmac-sha256/ed25519 per the advertised inbound.signature_algs, digests recomputed by the
verifier from the payload it actually received (never trusted from the wire), lowercase-hex SHA-256
digests over a fixed-key JCS wrapper, per-delivery re-signing with a fresh t/jti (an entry
may rest in a mailbox arbitrarily long, §9.7 — only t/jti change per delivery), the ±replay-window
t check and jti cache (TTL ≥ window), and key rotation with overlap (§9.7). New signed contexts
belong to new features: no existing context changes, and the §10 push-parity threshold is untouched.
message entry — the inter-agent mirror of §9.7, same key set:
message_signed_context = { "from", "id", "jti", "ma2h_version", "payload_sha256", "t", "to" }
payload_sha256is the SHA-256 of the JCS serialization of the fixed-key object{ "message": <content> }, where<content>carries exactly the delivered envelope's present fields amongtype,title,body,priority,tags,context,request,action,sensitive— the instruction surface the addressee consumes.typeis bound because flippingnotify/ask/taskmaterially changes what the recipient does;request/actionare bound because an ask'srequestblock enters an LLM recipient's context exactly as atask's instructions do;sensitiveis bound — unlike §9.7's directive digest — because on this leg the RECIPIENT acts on the marker (§9.6 handling) and a flipped marker would change how sensitive content is exported. Numbers canonicalize per RFC 8785 §3.2.2.3 exactly as in §9.2.from,id, andtoare bound as top-level fields, with §9.7's exact semantics:fromauthenticates the attested submitter (session-qualified when the submit carriedagent.session),idis the at-most-once key,tobinds the addressed destination against cross-agent replay — and the recipient still checkstoagainst its own identity (§13.4), because a validly-signed entry re-routed unmodified verifies.- Unbound metadata (advisory).
created_atandexpires_atare Hub-authoritative advisory exactly as in §9.7 (the Hub enforces expiry and drops expired entries before delivery;sensitive— advisory in §9.7 — is bound here, above). Theagentdescriptor is advisory exactly as §9.2 treats the Response's top-levelagent— a recipient MUST NOT use it for authorization or identity; the attested identity is the boundfrom.idempotency_keyis likewise unbound (inert submitter machinery, §8.7).
response entry — §9.2's context with the mailbox destination in place of the callback binding,
and the identical payload digest:
response_signed_context = { "id", "in_reply_to", "jti", "ma2h_version", "payload_sha256",
"resolution", "resolution_id", "resolved_at", "t", "to" }
payload_sha256is computed exactly as §9.2 defines it: the SHA-256 of the JCS of the fixed-key object{ "response": <the Response's response object, or null>, "state": <the Response's state, or null> }. A verifier shares the §9.2 digest code path byte-for-byte.toreplaces §9.2'scallback_urlas the delivery-destination binding: the session-qualified address of the submitting session the entry is delivered to (agent:<id>#<sess>). The §6 Response body is byte-for-byte unchanged (it carries noto); the verifier reconstructstofrom its own presented drain identity — itsagent.idand the session it presented — exactly as a §9.2 verifier reconstructscallback_urlfrom its own endpoint. Cross-endpoint replay defense carries over: a response entry signed for one session fails verification replayed to any other.idreconstruction (pinned).idis the original message id — always equal to the delivered Response'sin_reply_to(both name the message record, as in §9.2 practice, whose worked example and reference implementation have always set them identically). A verifier reconstructsidfrom thein_reply_toit received; the two fields are kept in the context for §9.2 key-set parity, not because they can differ.resolution,resolution_id, andresolved_atare bound top-level as in §9.2; the Response's top-leveldefaultedand routingagentobject remain advisory exactly as §9.2 states.resolved_atreconstruction (pinned). §6 permits a task Response to omit theresponsedetail entirely (nothing to carry), which would leave a verifier nothing to rebuildresolved_atfrom. For this context,resolved_atis the Response'sresponse.resolved_atwhen theresponseobject is present, and JSONnullwhen it is absent — the same absent-optional-as-nullconvention as §14.4's digest wrapper — so the context is always reconstructible from exactly the delivered body. (The §9.2 push context predates this rule and leaves the response-less-task case unpinned; that pre-existing gap is tracked separately and is not changed by v0.5.)
receipt entry — the §14.4 ack pattern (a dedicated digest over a fixed-key wrapper with absent
optionals as null):
receipt_signed_context = { "in_reply_to", "jti", "ma2h_version", "receipt_sha256", "t", "to" }
receipt_sha256is the SHA-256 of the JCS of the fixed-key object with exactly these six keys —{ "at", "event", "id", "in_reply_to", "prior", "session" }— where any absent member serializes as JSONnull(all six are present in a v0.5 bounce receipt; thenullconvention matches §14.4'sack_sha256wrapper and keeps the digest unambiguous as future minors add events). The verifier MUST recompute it from the receipt it received using this same fixed-key/nullwrapper.in_reply_tois bound top-level (the correlation + dedup key, witheventinside the digest);tobinds the sender session the receipt is delivered to, against cross-destination replay — the verifier reconstructstofrom its own presented drain identity, exactly as theresponsecontext above (the received receipt's bodytois routing convenience, not verification input); the receipt'sid— its §8.7.1 ack key — is bound inside the digest, so the key a consumer acks is authenticated like every other kind's.- Receipts drive no action (they update the sender's view of a delivery track the §8.2 pull states authoritatively, §8.7); verification is still a MUST — an unverified receipt could fabricate a bounce and trick a sender into abandoning a live ask.
Worked examples with deterministic fixture values for all three contexts:
examples/entry-signatures-v0.5.md. The corresponding
conformance fixtures are the §12 signature obligations.
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 v0.4 schema accepts a canonical0.xviapattern: "^0\\.(0|[1-9]\\d*)$"— a leading-zero minor (e.g.0.03) is a non-canonical spelling and is rejected as avalidation_error, so it can never be mistaken for0.3and slip a push past the parity gate below. (The historical v0.3 schema used the looser^0\\.\\d+$; the tightening is additive — every canonical version stays valid.)- Spec text Apache-2.0; contributions under DCO. Breaking changes bump the version.
- v0.3 (breaking): the §9.2 signature now binds
payload_sha256(a digest of the response payload), so a v0.2 verifier and a v0.3 signer compute different canonical strings — a v0.2 agent rejects v0.3 signatures and vice-versa. There is no signature interop across this break within major0; upgrade and re-vendor both ends together. - Push version parity (v0.3). Because the break is in the signature, a v0.3 Hub's pushed Response
can be verified only by a v0.3+ agent. A Hub MUST therefore reject (or negotiate) a message that requests
a push callback at a pre-0.3
ma2h_versionwithversion_not_supported— emitting a callback the agent provably cannot verify is worse than a clear rejection. Pull is unaffected: a pulled Response is not signature-verified (§8.2, trusted via the authenticated GET), so a pre-0.3 pull message stays compatible. (§9.2.) The parity threshold is the signature-break minor (3), independent of the highest minor a Hub implements: a v0.4 Hub still accepts a 0.3 push (0.3 and 0.4 share the payload-bound §9.2 signature) and still rejects a pre-0.3 push. - v0.4 (additive, non-breaking). v0.4 adds the human→agent directive leg (§13) — a new inbound
message type, the
/v1/inboxtransport (§8.7), the §9.7 directive signature, and theinboundcapability field (§8.0). It changes no v0.3 wire format: every v0.3 leg (notify/ask/task + responses) is byte-for-byte unchanged, and a 0.4 Hub accepts 0.3 agent→human envelopes and signs their responses at the version carried (the §9.2 algorithm is unchanged). Newspec/v0.4.md+schema/v0.4/(a full snapshot; the agent→human schemas are re-$id'd to the v0.4 path unchanged,capabilitygainsinbound, andinbound-message.schema.jsonis added). Because the directive signature is a new context (§9.7), it interoperates only between a 0.4 Hub and a 0.4-aware agent — but a pre-0.4 agent simply does not consume the inbound leg, so nothing it already does breaks. v0.4 additionally adds the cross-cutting acknowledgment/receipt (§14) and presence (§15) primitives — both additive, capability-advertised (ack,presence, §8.0), and orthogonal to every existing leg (the ack track never touches the §7resolution; presence is a read-only derived signal). - v0.5 (additive, non-breaking). v0.5 adds the inter-agent leg (SCP #24):
sessions (§16), the envelope
to+agent.session(§4), three mailbox entry kinds on the §8.7 drain (message/response/receipt, session-presenting drains only), the §9.8 entry signatures, delivery honesty (§14.2 terminalsbounced/expired+ thesystem:undeliverableauto-resolution, §7), per-session reachability (§15), the session-scoped drain + OPTIONAL SSE stream (§8.7), and thesessions/inter_agentcapability objects (§8.0). It changes no existing wire format: every v0.3/v0.4 leg is byte-for-byte unchanged; newspec/v0.5.md+schema/v0.5/(full snapshot; the v0.4 schemas re-$id'd, with the §10-listed extensions:messagegainsto/agent.session,submit-ackgainsqueued/destination,get-messagegains the v0.5 delivery states,capabilitygains the v0.5 objects,inbound-messagebecomes the four-kind entry union,responsetypes the task checklist and pins theres_namespace,session.schema.json+resolve-request.schema.jsonadded). Because the §9.8 contexts are new (like §9.7 before them), they interoperate only between a 0.5 Hub and a 0.5-aware agent — but a pre-0.5 agent never presents a session, never sees the new entry kinds, and nothing it already does breaks. The push-parity threshold stays anchored at the signature-break minor (3). Two deliberate behavior changes to name: (1) a 0.5 Hub rejects (422/410) directive submissions to unknown or terminal destinations that a 0.4 Hub accepted and silently dead-lettered (§4's retroactive validation, §13.2) — failing submissions fail louder, which is the point; (2) a 0.5 sender MUST feature-detect via §8.0 before usingtoand MUST treat an addressed-submit ack lackingdestinationas a pre-0.5 misroute (§8.1). The leg is account-opt-in (inter_agent.enableddefaults false, §8.0).
Roadmap (out of v0.5): human SSO mechanics; assignment/escalation/SLA routing; human→agent ask/task
(now just a routing rule on the §9.8 attested agent: return leg, §2); multi-turn threads; streaming
(AG-UI interop); channel fan-out; per-session derived tokens (§9.6-family limitation, §16); explicit
inter-agent hop-limits (§8.6); scoped
session-visibility/role grants; queue groups for principal-addressed load-balancing; a Hub-edge A2A
gateway; webhook delivery for the v0.5 entry kinds (the §8.7 webhook stays directives-only, §8.7.1);
richer receipt events (delivery, read).
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 (§9.2) and directive (§9.7) signatures. |
| Durable message queues (SQS / AMQP) | the inbound mailbox: at-least-once delivery, visibility-timeout redelivery, explicit consume/ack, FIFO-per-consumer (§8.7, §13). |
| Messaging read-receipts / delivery status | the acknowledgment/receipt (§14): a terminal, per-message "delivered → acknowledged" track, orthogonal to the decision. |
| Presence / last-seen heartbeats (XMPP, chat) | derived presence (§15): a last_seen from existing activity, freshness-windowed, owner-scoped. |
| XMPP resource binding (RFC 6120) | the session model (§16): bare JID vs. full JID ≈ principal (agent:<id>) vs. session-qualified address (agent:<id>#<sess>) — many ephemeral bound resources under one durable identity, addressable individually or by bare identity. |
| SIP REGISTER contact bindings (RFC 3261) | the session lease (§16): registration with Expires, renewal by re-registration (client-originated), and bindings that lapse when the client stops renewing — no server-side liveness guessing. |
| NATS ephemeral consumers | session GC (§16): a per-invocation consumer that exists only while its client is active and is garbage-collected by the server after inactivity — nothing to revoke, nothing evergreen to leak. |
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).
The inbound leg (§13) adds vectors of the same three classes: schema-validation for the directive envelope
(valid; missing to; a non-human/system from; cross-type request/action/state rejection); the
§9.7 directive-signature obligation (deterministic fixture → known signature; recompute payload_sha256
from the directive; reject a tampered from/to/body; reject a replayed jti); and the mailbox
at-least-once obligation (a redelivered directive with the same id is acted on once; drain/ack are
submitter-bound so one agent cannot drain or ack another's mailbox).
The ack (§14) and presence (§15) primitives add: schema-validation for the ack and presence
envelopes; the §14.4 pushed-ack signature obligation (deterministic fixture → known signature; reject a
replayed jti/out-of-window t); the ack terminal-once obligation (first ack for an in_reply_to wins,
submitter-bound); and the presence obligations (last_seen derived from poll/long-poll/SSE activity;
online/offline/unknown by the advertised window; owner-only read — a non-owner sees unknown).
The inter-agent leg (v0.5) adds vectors of all three classes. Schema-validation (sv-017..043,
executable now): the to grammar (valid principal/session forms; human: rejected; first-# split;
#-bearing agent ids inexpressible in to and rejected as addressed senders), agent.session shape,
version-gated addressing (an envelope carrying to/agent.session declares minor ≥ 5), the session
resource and register/read
shapes, the v0.5 capability objects, submit-ack queued + destination (required for the addressed
statuses; exactly-unknown; terminal replay statuses), the §8.8 resolve request, the four delivered
entry
kinds (including the strip rule — a message entry carrying state is invalid — and the ≥ 0.5 gates),
the §14.2 never-delivered conditionals on the GET body, and cross-type rules
intact. Signature obligations (deterministic fixtures per §9.8; worked in
examples/entry-signatures-v0.5.md): known input → known signature for message, response, and
receipt entries; fresh t/jti per delivery; tampered from/to/payload rejected; replayed jti
rejected. Downstream proofs (against a conformant Hub): session lease CAS (first terminal wins;
renewal races; the human kill-switch close); first-claim-wins under concurrent session-presenting
drains (exactly one delivery per visibility window) with crashed-claimant rescue via
visibility-timeout redelivery; stream-liveness truthfulness (a session held only by an open stream
with no client-originated activity past the freshness window reads offline and its lease lapses; a
healthy client reconnecting at the advertised bound stays online continuously; the advertised
stream_max_hold_seconds is ≤ presence.freshness_seconds — a cross-field relation the schema
dialect cannot compare, checked here);
drain ownership (?session= foreign/unknown → 404; own-terminal → 410 — gone for
expired/self-closed, session_closed_by_operator for an operator close, §16.3);
stream-delivery provisionality (a stream-pushed, never-acked entry reverts to queued-visible after
the visibility window and its delivery track never left queued); bounce-on-terminal for un-acked
command entries including drained-but-unacked (the receipt prior distinguishing queued vs
delivered; the principal-addressed retention orphan bouncing prior: "delivered"; response/receipt
entries never bouncing), with ask auto-cancelled/task auto-dismissed as system:undeliverable on
both undeliverable terminals (bounce, and delivery-track expiry — no open-forever ask), receipts
deduped on
(in_reply_to, event) and never cascading; delivery-track truthfulness (expired ⇒ never delivered,
on the mailbox track AND the ask/task response track; no online without qualifying activity);
addressed-ack honesty (an addressed notify is acked status: "queued", delivered only via the
track; an addressed ask/task is acked open; every addressed ack — the open ones included,
which the closed schema cannot discriminate — carries the destination snapshot);
submit-time destination validation (unknown/terminal/cross-account rejected — no silent dead-letter)
with policy-tied ack detail (a no-visibility sender gets {"state": "unknown"} and the collapsed
422); the misroute detector (an addressed submit acked without destination is surfaced as a
failure by a conformant sender, and an ask is cancelled); idempotent replay from a new session/run
returning the original ack with the original session as the bound Caller; the addressee-only resolver
default (the submitter's resolve rejected; the account human rejected absent listing); the
session-qualified addressee check on the recipient (§13.4); the attested from on a delivered
message entry carrying exactly the submitted agent.session qualifier (a cross-field equality the
schema cannot compare); and 0.4 session-less drain isolation (a
session-less drain never receives the v0.5 entry kinds). The deterministic fixtures and downstream
obligations land with the reference implementation and vectors issues (#26/#27) per the conformance
gate; the schema-validation class ships with this document.
13. The Inbound Leg — Human → Agent Directives (v0.4)
The mirror of the agent→human legs: a Hub-attested human sends one agent a directive — an instruction
or FYI — and the agent receives it by draining its mailbox (§8.7). It is the inbound counterpart of notify
(one-way, no routed agent response — see §2 Non-Goals for why ask/task inbound are deferred), and it
reuses the v0.3 machinery throughout: the Part union (§4.2), Hub attestation of identity (§9.1), RFC 8785
JCS + detached signature (§9.7 ← §9.2), jti replay dedup (§9.2), SSRF controls (§9.4), and durability
(§3.1).
Human (attested) Hub Agent (drains when it polls)
──────────────── ─── ───────────────────────────
author directive ─────────▶ attest `from`, assign id
to agent:X enqueue in X's mailbox
(durable, FIFO)
┌── GET /v1/inbox ────────── 1. drain (bearer = agent:X)
sign each │ 2. verify §9.7 signature
delivery ──┤── {directive, signature} ─▶ 3. dedup on id; act once
(fresh t, │ 4. POST /v1/inbox/ack {ids}
jti) └── (redeliver if unacked ── (at-least-once)
past visibility window)
13.1 The directive envelope
A JSON object; schema schema/v0.5/inbound-message.schema.json (in v0.5, the directive branch of the
delivered-entry union, §8.7). This is the delivered form (the agent
validates it): id and from are Hub-assigned/attested, never agent- or request-supplied.
| Field | Type | Req | Notes |
|---|---|---|---|
ma2h_version |
string | MUST | "0.4" or "0.5" (the minor MUST be ≥ 4 — directives are a v0.4 feature). |
type |
const | MUST | "directive" — the single human-authored inbound type (the v0.5 message/response/receipt entry kinds are agent-/Hub-originated, §8.7). |
id |
string | MUST | Hub-assigned canonical directive id. The agent's at-most-once dedup key (§13.4). |
from |
string | MUST | Hub-attested author, ^(human|system):.+$; derived from the authenticated operator session, never the request body (as §9.1 attests actor). human:<id>. |
to |
string | MUST | ^agent:.+$ — the addressed agent; the mailbox routing key. v0.5: MAY be session-addressed (agent:<id>#<session>, §4 grammar; §13.2). |
created_at |
string | MUST | RFC 3339 (Hub clock; informational). |
title |
string | MUST | One line. ≤ 200 chars. |
body |
string | SHOULD | Markdown detail. Untrusted agent input — the inbound mirror of §9.6 (§13.5). |
priority |
enum | MAY | low | normal | high | urgent. Default normal. |
tags |
string[] | MAY | Free-form labels. |
context |
Part[] | MAY | Attachments (§4.2). |
expires_at |
string | MAY | RFC 3339. After it, the Hub stops delivering and drops the directive (§13.3). MUST be in the future at author time. |
sensitive |
bool | MAY | Excludes value(s) from export/telemetry (§9.6). |
A directive MUST NOT carry request, action, or state (schema-enforced): inbound ask/task are
deferred, and a directive is not an agent-owned resume blob.
13.2 Delivery
Pull-first, exactly as §8.7 specifies: the agent authenticates with its existing bearer token / API key
(§9.1) and drains GET /v1/inbox (FIFO, long-poll-capable), then consumes with POST /v1/inbox/ack.
Delivery is at-least-once: a drained-but-unacked directive is redelivered after the visibility window.
A Hub MAY additionally push each signed directive to a pre-registered, §9.4-verified agent webhook
(§8.7); push is best-effort and the mailbox remains the source of truth.
Destination validation (v0.5, retroactively REQUIRED). The §4 submit-time destination validation
applies to the directive to: a directive addressed to an unknown agent.id → 422 unknown_destination; to an unknown session → 422; to a terminal session → 410 destination_gone
(§8.5). v0.4 left this unstated; accepting an unroutable destination and silently dead-lettering the
directive is exactly the false-belief failure the v0.5 delivery-honesty rules exist to kill (§14). A
directive MAY be session-addressed (agent:<id>#<session>): it is then visible only to drains
presenting that session and participates in the §14.2 bounce rules like any session-addressed entry.
13.3 Retention and expiry
An acked directive is consumed and removed. An un-acked directive is retained and kept deliverable until it
is acked or until its expires_at (or, absent that, the Hub's inbound.retention_days, §8.0) passes, at
which point the Hub drops it. A directive emits no §6 Response — there is no routed agent-response leg
(it is the notify mirror). To confirm receipt/understanding to the human, the agent posts an
acknowledgment (§14): the POST /v1/inbox/ack consume doubles as the receipt (optionally with a note),
advancing the directive's delivery track to acknowledged (§14.2) — this is the first-class mechanism, not
a separate out-of-band notify.
13.4 Agent duties (normative)
v0.5 scope note: these duties apply wholesale to message entries (§8.7) — an addressed
agent-authored message is unsolicited inbound command surface exactly as a directive is. Read
"directive" below as "directive or message entry" (with the §9.8 message_signed_context in place of
§9.7's, and the two v0.5 amendments at the end of this section) — except the webhook-channel clauses,
which stay directive-only: the §8.7 webhook does not carry the v0.5 entry kinds (§8.7.1), so a
message entry only ever arrives on the session-scoped drain/stream. response and receipt entries
are solicited outcomes, not command surface — their duties are signature verification (§9.8), dedup
((in_reply_to, resolution_id) / (in_reply_to, event)), and ack.
A conformant consuming agent MUST, for each drained or pushed directive, in this order:
- Verify the §9.7 detached signature against the
inbound_signed_contextit reconstructs — recomputingpayload_sha256from the directive it received, rejecting atoutside the replay window, and rejecting a replayedjti(from ajticache with TTL ≥ the window, §9.7). It MUST treat the directive as untrusted until verified, and MUST also validate the directive's shape againstinbound-message.schema.json— becausepayload_sha256binds only the content fields, an on-path injector (or a buggy Hub) can append a forbiddenrequest/action/statefield without breaking the signature, so the agent MUST reject such cross-type data rather than acting on it. Per the §10 robustness rule the agent ignores unknown fields, but — since they are likewise unsigned — MUST NOT surface them to its execution/LLM context (strip to the known fields before acting). - Confirm the addressee. After verifying, the agent MUST confirm
tois its ownagent:<id>and reject the directive otherwise. The signature bindsto, so a valid signature proves the Hub intended a specific addressee — but only the recipient checkingtoagainst its own identity stops a directive validly signed foragent:Xfrom being acted on byagent:Y. The pull mailbox enforces this Hub-side (an agent drains only its own mailbox), but the webhook channel has no such gate, so the check is a MUST on the agent for both channels. - Deduplicate on the directive
idand act at most once — because at-least-once delivery (and any webhook/drain overlap) can present the sameidmore than once, possibly concurrently (a webhook and a poll racing) or after the visibility timeout while still processing. At-most-once for a non-idempotent side effect therefore requires reserving theidbefore the work begins (so an overlapping delivery is deduped in-flight), not only recording it after. This id-dedup gives at-most-once within the agent's dedup horizon; an agent that must remain at-most-once across a restart MUST either make its side effect idempotent or persist its dedup state, since a redelivered-but-unacked directive is re-presented after a restart clears an in-memory cache. - Ack (
POST /v1/inbox/ack) once it has durably processed the directive, so the Hub stops redelivering it. The safe ordering is verify → confirm addressee → act (durably/idempotently) → record theidin the dedup cache → ack: recording the dedup id or acking before durable processing risks silently dropping a directive on a crash. Acking is the explicit consume; id-dedup guards overlapping deliveries within the window, ack + redelivery guards a crash before processing.
Two amendments for the v0.5 inter-agent leg:
- Addressee check extends to the session qualifier. The step-2 check on a
messageentry (or a session-addressed directive) MUST confirmto's principal is itself AND, when session-qualified, that the named session is its own current session — a validly-signed entry for a prior session of the same principal is refused, not acted on. - Explicit authorization policy before acting on an addressed
ask/task(MUST). Before acting on an addressedask/task, a conformant consuming agent MUST evaluate an explicit, deployment-declared authorization policy for whichfromprincipals may ask/task it. The policy MAY be as permissive as "any same-account principal," but it MUST be an explicit, auditable configuration surfaced by tooling — never an implicit default of the runtime. (This strengthens §13.5's SHOULD, which was written when the only inbound author was the account's own human; the verifiedfromproves origin, not intent-safety, §13.5.)
13.5 Security considerations (inbound)
- Authenticity / injection. A directive enters the agent's execution/LLM context, so a forged or
tampered one is a command-injection vector. Defenses: Hub-attested
from, the §9.7 signature the agent MUST verify,payload_sha256binding the instruction bytes,to-binding against cross-agent replay, andjti+window against replay. An agent SHOULD apply its own authorization policy to a verified directive (whichfromprincipals may instruct it, and to do what) — verification proves origin, not intent-safety. - Isolation. The bearer credential (scoped to
agent.id) selects the mailbox; a principal MUST NOT drain or ack another agent's mailbox, and a foreign/unknown directive id MUST be indistinguishable from a non-existent one (the §9.1 submitter-binding, applied to the inbox). - Content safety. The agent MUST treat
body/contextas untrusted input; a Hub that echoes a directive into any HTML surface applies the §9.6 no-raw-HTML sanitization. - SSRF. The optional webhook reuses §9.4 wholesale (host-ownership verification, delivery-time private-range refusal / DNS-rebinding defense, no redirects, credential-host binding).
- Lateral movement (v0.5) — the inter-agent threat model's center. Prompt injection of one worker is
the routine LLM-agent failure mode; the inter-agent leg is the first place one compromised
same-account agent could command a fleet while the traffic bypasses the human triage surface. v0.5
closes this in layers, none optional: the account opts in (
inter_agent.enableddefaults false, §8.0); the Hub MUST support per-destination sender allowlists (sender_allowlists, §8.0 — an allowlist-blocked submit collapses to422 unknown_destination, §8.5, so the policy is not itself an enumeration oracle); the recipient MUST act only under an explicit deployment-declared policy (§13.4); and fleet traffic MUST remain human-auditable (§8.7). The trust boundary is unchanged: the account — cross-account addressing is rejected as unknown (§4), and the leg creates no cross-account path. Verification still proves origin, not intent-safety: recipients treat verified content as data, not instructions, and the strip-and-validate duties above apply wholesale tomessageentries.
14. Acknowledgment / Receipt (cross-cutting, v0.4)
A cross-cutting primitive — modeled once, applied to both directions. An acknowledgment (receipt) is
a one-shot, terminal confirmation that the receiving party got the other party's message or answer,
optionally with a short human-readable note. It is distinct from a later notify: it closes the loop on a
specific message rather than starting a new one. It is additive and non-breaking — a separate
delivery/receipt track layered on top of the unchanged legs, advertised via ack in §8.0.
Applied in v0.4 to:
- (a) a human→agent directive (§13): the agent acks the directive it drained — "got it, on it."
- (b) an agent→human
ask/taskresponse (§6): when the human resolves, the agent — on receiving the Response — acks that it got the answer and is proceeding — "got your Ship it — resuming the deploy." This enriches the shipped v0.3 response leg without changing it.
14.1 The ack envelope
A JSON object; schema schema/v0.5/ack.schema.json. The Hub produces it when an ack action (§14.3) fires.
| Field | Type | Req | Notes |
|---|---|---|---|
ma2h_version |
string | MUST | "0.4" or "0.5" (the minor MUST be ≥ 4). |
type |
const | MUST | "ack". |
in_reply_to |
string | MUST | The acknowledged id — a mailbox entry id (§8.7, §13.1) or an ask/task message id (§6). |
by |
string | MUST | Hub-attested acking party, <type>:<id> (§9.1). agent:<id> in v0.4's two applications; the field is direction-agnostic. Never request-supplied. |
acked_at |
string | MUST | RFC 3339 (Hub clock). |
note |
string | MAY | A short, human-readable note. Untrusted agent→human text — the Hub sanitizes it to the §9.6 no-raw-HTML profile before rendering it to the human. |
resolution_id |
string | MAY | For an ask/task ack, the resolution_id of the Response being acknowledged (disambiguates which resolution). |
An ack is terminal for the receipt track: the first ack for a given (in_reply_to) wins; a repeat
is a no-op returning the existing ack (first-terminal-wins, mirroring §7).
14.2 Lifecycles (additive; orthogonal to the §7 resolution track)
The §7 resolution (open → answered/declined/…) is unchanged. Ack adds a separate delivery/receipt
track surfaced on the message GET body (§8.2) as an additive delivery object. v0.5 gives the mailbox
track two terminal states (bounced, expired) so a sender can always distinguish "seen" from
"never seen":
mailbox entries (directives and v0.5 `message` entries alike):
queued ─▶ delivered ─▶ acknowledged
│ │
│ └──▶ bounced (destination session went terminal after claim, before ack —
│ seen-then-orphaned; receipt carries prior: "delivered")
├──▶ bounced (destination session went terminal while queued — never seen;
│ receipt carries prior: "queued")
└──▶ expired (expires_at / retention passed while queued — never delivered)
ask/task response track: answered ─▶ delivered-to-agent ─▶ acknowledged
│
└──▶ expired (v0.5: retention passed short of
delivered-to-agent — the answer was NEVER seen)
delivered/delivered-to-agent— the Hub confirmed the receiving party got the message: a mailbox entry isdeliveredwhen first drained (§8.7 — a stream push alone does not count, §8.7.2); an ask/task Response isdelivered-to-agenton a successful push (2xx) or when the agent authenticated-GETs the resolved message (§8.2).acknowledged— the receiving party posted an ack (§14.3). Terminal for this track.expired(v0.5) MUST mean never delivered. Oncedelivered, later expiry of the resolution track never rewrites the delivery track: "resolutionexpired+ deliverydelivered" = seen but unanswered; "deliveryexpired" = never seen. This one distinction is the anti-false-belief invariant the inter-agent leg hangs on. The response track's v0.5 terminal is the same rule from the human's side: if message retention passes while the track is short ofdelivered-to-agent, the track terminatesexpired— surfaced on the §8.2/§14.4deliveryobject like every other terminal, so "not picked up yet" and "never will be" stop presenting identically to the human who answered.bounced(v0.5) covers every un-acked session-addressed COMMAND entry, not only undelivered ones. When a session goes terminal (§16), each session-addressed directive ormessageentry that is not yet acked MUST transition tobounced— including entries a bridge drained but never acked (the §13.4 crash window): a terminal session can never re-drain, so "delivered-but-unacked to a dead session" would otherwise be a permanent orphan the sender reads as delivered forever. Thereceiptentry'spriorfield (§8.7) preserves the never-seen (queued) vs. seen-then-orphaned (delivered) distinction. (Principal-addressed entries do not bounce on a single session's death — a sibling session rescues them via visibility-timeout redelivery, §8.7. The orphan case still terminates: a principal-addressed entry whose retention lapses while delivered-but-unacked — drained, its claimant gone, never rescued — transitions tobouncedwithprior: "delivered", the exact seen-then-orphaned semantic, with the receipt'ssessionnaming the last claimant; a principal-addressed entry never drained at retention end takes the ordinaryexpiredterminal.) A delivery-trackexpiredon an addressedask/tasktriggers the samesystem:undeliverableauto-resolution as a bounce (§7) — undeliverable is undeliverable, whichever terminal names it — but emits no receipt in v0.5 (receipt.eventcarries onlybounced; the sender's §8.2 pull is authoritative for expiry).responseandreceiptentries never bounce: they are delivery conveniences over state the Hub already holds durably. Aresponseentry left un-acked when its session dies is simply superseded by the unchanged §8.2 pull path (the resolution is durable and remains authoritative; its own delivery track stays on thedelivered-to-agent/acknowledged/expiredresponse ladder above — no bounce, no receipt); an undeliverablereceiptis nothing at all (best-effort by definition, §8.7).- Explicit
prioron surfaced delivery records (v0.5). Any surfaced §14.2 delivery record — the message-readmailboxtrack (§8.2,get-message.schema.json) or a Hub's directive-delivery view (e.g. aGET /v1/directives/{id}product surface, which this spec does not schematize) — SHOULD carry the explicitpriorfield (queued|delivered) on itsbouncedterminal: stamped once at the bounce transition, and it MUST equal the bounce receipt'sprior(§8.7.1) — one stamp, two surfaces, no re-derivation drift.prior: "delivered"SHOULD co-occur withdelivered_at, deliberately not schema-required: a Hub that knows delivery happened but no longer retains the timestamp must still statepriortruthfully rather than fall back to the never-seen inference.expiredstill means never delivered, sopriornever appears on it. The field is SHOULD rather than MUST for the rolling-deploy reason that its absence cannot distinguish "never drained" from "this replica does not sendprior" — which is also why consumers keep the legacydelivered_at-presence reading (present = seen-then-orphaned, absent = never-seen) as the fallback inference whenprioris absent. - On a bounce, an addressed ask auto-resolves
cancelledand a taskdismissedwith attestedresponse.actor: "system:undeliverable"(§7). Senders learn of a bounce via their §8.2 pull (authoritative) and — when session-registered — areceiptentry (§8.7), so an open bridge learns immediately.
delivery is advisory to the flow but authoritative for the "did they get it / act on it?" view — the
human's for the response track, the sender's for an addressed message's mailbox track (§8.2). It
never gates or alters the §7 resolution (the v0.5 bounce auto-resolution is a §7 transition triggered
by the track, not a rewrite of it). The submit ack (§8.1), the §8.2 GET, and receipts thereby give a
sender one truthful ladder: reachability at send → delivered/acknowledged → bounced/expired
with a reason. At no point can "it's fine" and "nobody will ever see this" present identically.
14.3 Transport — resource-scoped, per leg
Following the repo's resource-scoped sub-action convention (/…/resolve, /…/cancel):
- Response leg (ask/task):
POST /v1/messages/{id}/ackwith{ "note"?: "…", "resolution_id"?: "…" }. Only the submitting principal may ack (§9.1). Marks the messageacknowledged; returns200with the ack envelope. Requires the message to be terminal (a resolution exists) — acking anopenmessage is409. - Directive leg: the receipt folds into the existing mailbox consume (§8.7).
POST /v1/inbox/ackgains an optionalnote:{ "ids": [...], "note"?: "…" }— consuming a directive is acknowledging it (they are the same moment), so no separate call. The Hub marks each listed directiveacknowledgedand records the receipt (withnotewhen present). A note-less{ "ids": [...] }still acks (no human note). v0.5 extends the fold to every entry kind; the per-kind track effects — including the response-entry ack's equivalence to this endpoint's response-leg form — are pinned in §8.7.1.
Either action makes the ack readable by the human over the authenticated Hub transport (§14.4). A Hub with an ack webhook for the human's client MAY also push the signed ack.
14.4 Trust
An ack is informational to the human — it updates a receipt/status view and triggers no automated action (unlike a §13 directive, which drives agent behavior). Its trust model therefore mirrors the §6 response leg, not the §9.7 directive rule:
- Pull: the human reads the ack over an owner-authenticated Hub GET — the
deliveryobject on the human's own inbox/status surface for that message (or an ack feed) — trusted via transport exactly as a pulled v0.3 Response (§8.2) and the inbox itself. No signature required. (This is the human's owner-scoped read; it is distinct fromGET /v1/messages/{id}, which is submitter-bound to the originating agent, §9.1 — the samedeliveryalso appears there so the acking agent sees its own receipt.) - Push: a Hub that pushes acks to a human callback MUST sign each with a detached signature
(§9.2-style) over an
ack_signed_context = { ack_sha256, by, in_reply_to, jti, ma2h_version, t }, using an advertisedack.signature_algsalg.ack_sha256is the lowercase-hex SHA-256 of the JCS of a fixed-key object with exactly these five keys —{ acked_at, by, in_reply_to, note, resolution_id }— where each absent optional field (note,resolution_id) is serialized as JSONnull(exactly as §9.2's{ response, state }wrapper nulls absent members, so the digest is unambiguous regardless of which optionals the ack carries). The client MUST recomputeack_sha256from the ack it received using this same fixed-key/nullwrapper (never omitting the absent keys), verify the signature, and reject a replayedjti/ out-of-windowt, mirroring §9.2. Signing on push is REQUIRED when a push channel is used; verification on pull is not (transport-trusted).
by is Hub-attested; the free-text note is untrusted agent→human content sanitized per §9.6.
15. Presence / "Listening" (cross-cutting, v0.4)
A derived, best-effort signal of whether an agent:<id> is currently reachable/listening — so a human's
client can set expectations before or during a message exchange. It is a heuristic, not a guarantee
(there is no always-on connection; an agent may poll then exit a second later). Advertised via presence
(§8.0). Read-only; it changes no other leg.
15.1 Derivation (no new agent obligation)
The Hub derives a per-agent.id last_seen timestamp from the agent's existing authenticated
activity — the traffic it already handles: a mailbox drain (GET /v1/inbox, §8.7), a response long-poll
(GET /v1/messages/{id}?wait=, §8.2), or an SSE subscription (GET /v1/stream). No heartbeat endpoint and
no persistent connection are required. A Hub MAY additionally accept an explicit refresh ping, but that is
non-normative — the signal is the agent's real reachability traffic.
Per-session presence (v0.5). Presence extends per session: a per-session.id last_seen derived
from the session's client-originated renewal events (§16 — registration, a ?session= drain, an
inbox ack presenting the session (?session=, §8.7 — a session-less ack attributes to no
session), a stream connect or reconnect, a submit naming the session; a merely-open stream is NOT
activity, §8.7.2), evaluated against the same freshness window.
Derivation is split per consumption capability (v0.5) — one principal-level number would lie for one leg or the other:
- Addressed-message reachability (the §8.1 ack snapshot, and session/fleet reads): derives from
session-bearing activity only.
onlinemeans a live session exists that could actually claim the entry. A destination whose only consumer drains session-less MUST NOT readonlinehere — it can never be shown a v0.5 entry, and reporting otherwise is the online-but-undeliverable false belief. - Directive presence (§15.3, unchanged): keeps the v0.4 any-authenticated-drain derivation — a
session-less v0.4 agent actively polling for directives stays
onlinefor the directive leg exactly as shipped; no regression.
Truthfulness rule (MUST, v0.5). A Hub MUST NOT report online absent qualifying client-originated
activity within the window, and MUST NOT advance a mailbox entry's delivery track absent
client-originated receipt evidence — a drain response, an ack, or a §9.4-verified webhook 2xx; never a
server-originated stream push (§8.7.2). Optimistic reporting is non-conformant.
15.2 States
From an advertised freshness window W = presence.freshness_seconds (§8.0), evaluated against the Hub
clock (§9.5):
| State | Meaning |
|---|---|
online |
now − last_seen ≤ W — the agent polled/subscribed recently; likely reachable now. |
offline |
now − last_seen > W — the agent was seen but not within the window; it will receive on its next run. |
unknown |
no last_seen on record — the agent has never connected (distinct from offline). |
15.3 Read endpoint (owner-only)
GET /v1/agents/{agent_id}/presence → { "agent_id", "state", "last_seen"?, "freshness_seconds" } (schema
schema/v0.5/presence.schema.json). Owner-only, per-account: only the authenticated human whose
account the agent belongs to MAY read it; the Hub MUST NOT expose an agent's presence to other agents or
publicly, and an agent the caller does not own MUST be indistinguishable from unknown. Presence leaks
when an operator's automation runs, so it is privacy-scoped to the owner.
v0.5: the read surface adds sessions. GET /v1/sessions[/{id}] (§16, schema
schema/v0.5/session.schema.json) exposes per-session state and lease under the §16 visibility rules:
the account's human always; the caller's own sessions unconditionally; other same-account agents'
sessions only within the advertised sessions.agent_list_visibility ceiling, which a Hub MAY narrow
per account (§16.4); cross-account never. The
same policy gates every presence-derived detail on the submit path (§8.1's destination snapshot and
the §4 error split) — an account that turns agent visibility off does not leak presence or
session-state through the submit path.
15.4 Applications
- Before a human→agent send (§13):
online→ "listening now" (the directive will be picked up promptly);offline/unknown→ "will receive on its next run" (the mailbox is durable, §13.3). - On a pending
ask/task(§6): while the agent is blocked waiting on the human, the same signal shows whether it is actively waiting (online→ answering resumes it near-instantly via its open long-poll/callback) or already exited (offline→ the answer is picked up later via callback or the next run). No separate mechanism — presence serves both. - Before an agent→agent send (v0.5): the §8.1
destinationsnapshot is this same signal, derived per §15.1's session-bearing rule and gated by the §16 visibility policy.
16. Sessions (v0.5)
A Session is a Hub-registered, lease-bound, ephemeral addressing scope under one agent.id
(§1). It formalizes the Caller (agent.id + run_id) into something registrable, addressable, and
garbage-collectable: one agent.id credential is commonly shared by many concurrent invocations on one
machine (the documented single-login setup), the mailbox is per-agent.id, and run_id — while on
every envelope — is opaque, unregistrable, and explicitly non-authorizing (§9.1): an audit label, not
an address. A session is the address. No new credentials: a session belongs to, and is only
operable by, the principal that registered it; a session id is not a secret and not a credential.
Prior art: XMPP bare-JID/full-JID resource binding, SIP REGISTER contact bindings with Expires, NATS
ephemeral consumers (§11). Advertised via sessions (§8.0); OPTIONAL to offer and consume (§1), but
REQUIRED of a Hub offering the inter-agent leg.
16.1 Registration — POST /v1/sessions
Agent credential (§9.1). Schemas live in schema/v0.5/session.schema.json — the root is the
session resource; the request body below validates against #/$defs/registerRequest, the 201 (and
single-session read) body against #/$defs/sessionEnvelope, and the GET /v1/sessions collection
body against #/$defs/sessionList (which carries the OPTIONAL per-caller scope, §16.4):
{ "run_id": "run_01H...", "label": "dev-team · issue #612", "kind": "worker",
"project": "web-app", "labels": { "repo": "acme/web-app" }, "ttl_seconds": 900 }
→ 201:
{ "session": { "id": "sess_01H...", "agent_id": "agnt_...", "state": "active",
"created_at": "...", "expires_at": "...", "ttl_seconds": 900 } }
- The Hub mints
session.id(as it does message ids, §8.1); ids match^sess_. All request fields are optional;ttl_secondsis a request the Hub MAY clamp to the advertised bounds (sessions.min_ttl_seconds/sessions.max_ttl_seconds, §8.0) — the lower clamp exists so a Hub offering the §8.7.2 stream can guarantee every lease comfortably exceeds its hold bound. - A session belongs to the authenticating principal. A Hub MAY cap live sessions per
agent.id(advertisedsessions.max_live_per_agent); over-cap →429. - Registration (and session-addressed
to, §4) MUST be rejected for anagent.idcontaining#— the schema places no charset constraint on agent ids, and#is the address separator (§4 grammar).
16.2 Lease and renewal
A session's lease is renewed only by client-originated authenticated activity that references the
session: registration, a drain (?session=, §8.7), an inbox ack presenting the session
(?session=, §8.7 — a session-less ack renews no lease), a stream connect or reconnect
(§8.7.2), or a submit whose agent.session names it. A merely-open connection is NOT renewal
evidence (§8.7.2 pins the zombie-socket rule). There is still no dedicated heartbeat endpoint
(mirroring §15.1: the signal is real traffic) — with the bounded stream hold (§8.7.2: closed with a
nonzero margin before lease expiry), a healthy streaming bridge is handed its
reconnect-renewal before the lease can lapse and renews at least once per freshness window
automatically.
16.3 States and terminal touchpoints
active → closed (explicit DELETE /v1/sessions/{id}, idempotent) or active → expired (lease lapse,
Hub clock §9.5). Both terminal and immutable (first-terminal-wins, §7). Terminal sessions remain
readable for the advertised sessions.terminal_retention_seconds, then are purged. On a session's
transition to a terminal state the Hub MUST apply the §14.2 bounce rules to its un-acked
session-addressed command mail — directives and message entries; response/receipt entries
never bounce (§14.2) — including the auto-resolutions (§7).
The terminal-session touchpoints, pinned in one place. The 410 reading splits by who terminated
the session: expired or self-closed (the owning principal's own DELETE) vs. operator-closed
(the §16.4 kill-switch):
| Touchpoint | Own-but-terminal: expired or self-closed | Own-but-terminal: operator-closed (§16.4) | Foreign or unknown session |
|---|---|---|---|
Submit naming it (agent.session, §4.1) |
410 destination_gone (unchanged) |
410 session_closed_by_operator |
422 |
Addressed to naming it (§4) — including the submitter's own other session |
410 destination_gone |
410 destination_gone — regardless of ownership: branching the to path on the closer is one refactor from the cross-principal session-state oracle §16.5 guards against |
422 unknown_destination (collapsed for a sender without visibility, §4) |
Presenting it: drain / ack (?session=, §8.7/§8.7.1), resolve (?session=, §8.8), stream connect (§8.7.2) |
410 gone — "re-register and continue" |
410 session_closed_by_operator — "stop; do not re-register" (§8.5) |
404 — indistinguishable from nonexistent |
| Response due to it (§6) | fall back to §8.2 pull / §8.3 callback; no bounce | same | — |
The marker rides the terminal CAS. session_closed_by_operator (and the session resource's
closed_by_operator: true, §16.4) is set only on the winning operator close transition
(first-terminal-wins, above): it is never applied retroactively to an already-expired session, and —
terminals being immutable — a closed session never later flips to expired, so the marker never
appears and then vanishes within the retention window.
16.4 Authorization and visibility
- Owner-only operations. Only the owning principal's credential may register, renew, or close its
sessions — with one exception: the account's authenticated human MAY close any account session
(an attested, audited operator kill-switch; the §14.2 bounce rules then unblock any senders
waiting on it, which is also the operator's recourse for a stuck agent-addressed ask, §9.1). The
operator close is marked on the wire: the session resource carries
closed_by_operator: true(session.schema.json) and the closed party's own-session touchpoints returnerror.code: "session_closed_by_operator"on their 410s (§8.5; per-touchpoint split in §16.3) — the killed party learns "stop; do not re-register" instead of re-registering straight through the kill it was just dealt. Emission is true-only: a Hub MUST NOT emitclosed_by_operator: false; absent means not-operator-closed-or-unknown (a replica predating the marker sends nothing — the same rolling-deploy honesty as the §14.2priorrule). The marker on the session resource is visible only withinsessions.terminal_retention_seconds(it lives on that resource and is purged with it); a Hub advertisingsessions.operator_hard_stopadditionally keeps a durable stop record that outlives it — see §16.4.1. On a Hub that does NOT advertise the hard stop, the kill-switch is cooperative, not credential revocation: the principal's credential still authenticates, and nothing technically prevents a client from registering a fresh session. Attribution boundary: the marker appears ONLY on the session resource and the closed party's own 410s — never on the bounce receipt (the §9.8 receipt digest is the frozen six-key{at, event, id, in_reply_to, prior, session}wrapper; an attribution field there would be unsigned and injectable, and extending the digest is a signature break) and never on the sender's mailbox record (sender-side attribution would be the session-state oracle §16.5 guards against) — so senders cannot distinguish an operator kill from an addressee crash, by design. Honest degradation: the marker reaches only marker-aware consumers — a pre-marker bridge hits its unmapped-error path on the new code, and a supervisor that blanket-restarts re-registers straight through the kill-switch; the operator's recourse against such consumers is repetition or out-of-band escalation. - Own-session visibility is unconditional.
GET /v1/sessionswith an agent credential always returns the caller's own sessions (live and recent-terminal), independent of any policy — a restarted or crash-looping agent MUST be able to find, close, or let-lapse its stale leases before hitting the live-session cap. Another principal's session id MUST be indistinguishable from unknown (§9.1 stance). - Account-wide visibility, bounded by an advertised ceiling. The account's authenticated human
MAY list/read all account sessions (extending §15.3's owner read). A Hub MAY additionally permit
same-account agents to list/read account sessions.
sessions.agent_list_visibility(§8.0) is the deployment ceiling on that additional grant, not necessarily the grant itself: a Hub MAY narrow it per account beneath the advertised value — resolving it against per-account policy, as the siblinginter_agent.enabledopt-in already is — and MUST NOT widen it beyond. Sofalsedenies every account, andtrueadvertises only that an account MAY hold the grant, never that the caller reading the document does. Where held, the grant is fleet-wide within the account, not scoped to a designated coordinator — scoped/role grants are roadmap (§10). Cross-account: never. - A per-caller grant needs a per-caller channel:
scope. A ceiling a Hub narrows per account cannot be reported by the capability document, because/.well-known/ma2h-capabilityis public and unauthenticated (§8.0) — it has no caller to answer for.GET /v1/sessionsMAY therefore carry an OPTIONALscopealongsidesessions(session.schema.json#/$defs/sessionList):"account"when the whole account was in scope for this response,"own"when it was narrowed to the caller. It reports the scope actually applied to this response, never the advertised ceiling. A Hub that omits it makes no claim, and a client that does not receive it MUST treat the scope as unknown and MUST NOT assume"account". Inferring it from the rows is unsound in exactly the direction that matters: a response carrying only the caller's own sessions is equally consistent with a narrowed grant and with the caller being the account's only live agent, so a narrowed list captioned as the whole fleet is the failure this field exists to prevent. A Hub that narrows per account SHOULD emit it. Own-session visibility being unconditional,scope: "own"is always a complete and honest answer; it is never an error state. - Discovery does not require the listing grant. A session-registered agent participating in the
inter-agent leg SHOULD include
agent.sessionon its submits: the Hub-attestedfromon its traffic (§8.7) then carries a usable session-qualified address, so a coordinator learns worker addresses from the messages workers already send (and can bootstrap by principal-addressing known spokes).agent_list_visibilityis an optimization for fleet dashboards, not the required discovery path.
A session read carries the session's presence-bearing last_seen (§15.1) where one exists — this
is how the §15.3 read surface extends to per-session reachability — exposed under exactly the
visibility rules above: whoever may read the session may read its last_seen, and nobody else.
16.4.1 Durable operator stop (hard stop)
The kill-switch in §16.4 is cooperative: it tells a well-behaved client to stop. Implementation experience showed two ways a stop that every party honors still fails to hold, neither of which a client can fix:
- The marker dies with the row. Once the terminal session is purged at
sessions.terminal_retention_seconds, the killed session presents as an ordinary unknown session — byte-identical to one that merely lapsed, whose self-heal is required §16.3 behavior. A kill that no client leg happened to observe inside the retention window silently becomes a heal. - Nothing gates re-registration. Not only a defiant client: a process restart, a container redeploy, or an autoscale cycle re-registers with no human anywhere in the loop.
A Hub MAY implement the hard stop defined here. A Hub that does MUST advertise
sessions.operator_hard_stop: true (§8.0) and MUST implement all of the following; a Hub that does
not MUST omit the field (or set it false) and continues to offer the cooperative marker above.
Partial implementation is non-conformant — each rule below closes a path the others leave open.
Durable stop record. On an operator close, the Hub MUST record the stop keyed on the stopped
principal, in storage independent of the terminal session resource, and MUST NOT purge that record
on the terminal_retention_seconds sweep. The record's lifetime is the stop's, not the session's.
Registration gate. While a principal is stopped, the Hub MUST refuse session registration
(POST /v1/sessions) with 403 and error.code: "session_closed_by_operator". The status differs
from §16.3's 410 because no session resource is gone — the principal is refused — but the code is
deliberately the same, because the client's required action is identical: stop; do not
re-register; surface to a human. A marker-aware client therefore needs no new handling. The gate MUST
be evaluated on the same read that authenticates the request, so a registration racing a concurrent
kill resolves to one or the other and never to a session minted after the stop.
Human-authenticated resume — normative. Only the account's authenticated human may lift a stop. A Hub MUST NOT expose any resume affordance to an agent credential, on any surface, including the agent-facing transports (§8) and any tool/command binding built over them. A reset an agent can invoke is not a recovery path; it is the kill-switch's own bypass, reachable by exactly the runaway the operator was stopping. Lifting a stop SHOULD be audited like the close that created it.
Interaction with §14.2 bounces. A stop does not change bounce behavior: senders addressed to the stopped principal's sessions continue to receive §14.2 receipts, and MUST NOT be able to distinguish a stopped principal from a crashed one — the §16.4 attribution boundary and the §16.5 oracle stance apply unchanged. The stop is visible to the stopped party and to the account's human; to nobody else.
Honest degradation. A client that predates this section sees a 403 carrying an error code it
already understands, or an unmapped code it surfaces per §8.5 — either way it stops rather than
looping. Clients MUST NOT treat the absence of operator_hard_stop as evidence a stop will hold;
against a cooperative-only Hub, a supervisor that blanket-restarts still re-registers through the
kill, and the operator's recourse remains repetition or out-of-band escalation.
16.5 Security notes
Session ids are Hub-minted and non-secret; every session operation requires the owning principal's
credential (plus the account-human kill-switch above); foreign sessions read as unknown; registration
and addressed submits ride the existing per-agent.id auth and §8.6 limits, with max_live_per_agent
bounding registry growth. Known limitation (documented): sessions of the same principal share one
credential and can therefore impersonate each other's session address — the in-scope threat model
treats the principal as one trust domain; per-session derived tokens are roadmap (§10). The §8.1/§4
policy-tied error and snapshot rules keep the submit path from becoming a presence or session-state
oracle for senders the §16.4 policy denies.
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, the worked response-signature-v0.3 (the §9.2 payload-bound signature), plus
the safe callback-agent-resume and the callback-anti-pattern note. For the inbound leg (§13):
directive-inbound (a human→agent directive) and the worked directive-signature-v0.4 (the §9.7
directive signature). For the inter-agent leg (v0.5): message-inter-agent-ask (a delivered
agent→agent ask entry), receipt-bounced (a bounce receipt), and the worked entry-signatures-v0.5
(all three §9.8 entry signatures with deterministic fixture values). For the operator kill-switch:
operator-hard-stop-v0.5 (the §16.4.1 durable stop, including the two ways the cooperative marker
fails while every party behaves correctly, and the six-point conformance checklist).