MA2HSpec v0.6FAQOH HAI (hub)GitHub
MA2H / Spec / v0.6

Current version · rendered from /spec/v0.6.md · plain text for agents: /llms-full.txt · other versions: v0.5 v0.4 v0.3 v0.2 v0.1

MA2H — Multi-agent to Human Protocol

Version: 0.6 (Draft) · Date: 2026-09-13 · License: Apache-2.0 · Steward: Autonomy

Supersedes v0.5. v0.6 is a corrective release scoped to the ask contract (§5.2, §6) — the one part of MA2H where an agent hands a human a set of choices and gets an answer back. Every other leg is byte-for-byte unchanged.

It closes two mistakes that let a Hub accept an ask no human could answer or whose answer was ambiguous, and resolves three undefined fields that had shipped on the wire with no semantics. Two of the four changes narrow what a Hub accepts, which is why this is a minor bump and not an amendment to v0.5: a submit that v0.5 accepted may now be rejected. In both cases the ask it produced was already broken — v0.6 moves the failure to submit time, where the agent can see it, instead of resolve time, where only the human can. 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.6) adds no leg. Every version before it extended MA2H outward — v0.2 hardening, the v0.3 payload-bound signature, the v0.4 inbound leg, the v0.5 inter-agent leg — and each was additive by construction. v0.6 turns inward and repairs the ask contract (§5.2, §6), the oldest surface in the protocol and the one every other leg now routes answers through.

Four defects, one cluster. Two are mistakes: request.schema constrained the JSON container rather than the contract it describes, so a Hub could accept a mode=input ask that no answer could ever satisfy; and options[].value carried no uniqueness constraint, so two options could share a value and leave response.value ambiguous — the agent unable to tell which choice the human made. Two are underspecification: permissions.allow_edit with its Response twin edited, and permissions.allow_accept, shipped on the wire, in both schemas, and (for edited) inside the §9.2 signature, with no normative text anywhere. v0.6 defines the first pair — they turn out to be the missing answer to a real and frequent problem, a select whose options do not fit — and removes the third.

The through-line is that all four failed silently, and silently in the same direction: toward the human, who cannot fix any of them, and away from the agent, who can. A protocol that lets an implementer construct an unanswerable ask, or hands back an answer that cannot be attributed to a choice, has not finished specifying the ask. Every fix below moves a failure earlier and makes it loud.

A 0.6 Hub remains backward-compatible with 0.3/0.4/0.5 envelopes on every leg except the two narrowed ask shapes, which were never answerable to begin with (§10). 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.

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

Non-Goals (v0.5)

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:

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

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

3. Architecture

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

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

3.1 Durability and Conformance

A conformant Hub MUST durably persist message lifecycle such that a Hub process restart does not lose:

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.6/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 — a FLAT JSON Schema describing the ANSWER
    "type": "object",          //   OBJECT. MUST be type "object" with >= 1 `properties` entry (§5.2).
    "properties": { "reason": { "type": "string", "x-ma2h-sensitive": false } },
    "required": ["reason"]
  },
  "permissions": {             // which affordances/terminals the human is offered (§7)
    "allow_edit": false, "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 */ }
}

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.6/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,                      // true iff `value` is NOT a member of options[].value (§6)
    "actor": "human:alice",          // Hub-attested; ^(human|agent|system):.+$ (§9.1)
    "resolved_at": "2026-06-04T18:04:11Z",
    "comment": "Let's get a human eye on the migration first."
  },
  "defaulted": false,                   // true iff resolution came from default_on_expire (actor=system:...)
  "state": { "...": "..." }             // opaque agent blob, round-tripped verbatim (§9.3)
}

Resolution 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:

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.6/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.6/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.6/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=.." } ] }

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=.." }
] }

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):

The delivered payloads all validate against schema/v0.6/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.

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.6/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) or the input object validating against request.schema (input), per §5.2. For select/confirm it MUST be a member of options[].value unless the request set permissions.allow_edit:true, in which case a non-member string is accepted and the Hub sets response.edited:true (§5.2, §6). 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.

9. Security and Privacy

9.1 Authentication and authorization

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.

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

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

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" }

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" }

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" }

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

Roadmap (out of v0.6): 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):

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

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.

The ask contract (v0.6) adds vectors of two classes, and the split between them is itself the point. Schema-validation (sv-067..074): the mode=input schema constraint (a scalar schema and a property-less one both rejected; the answer-object shape accepted, with or without an explicit type), options uniqueItems, allow_edit's type and acceptance, and allow_accept's continued acceptance as an ignored unknown field. Downstream proof (dp-026, dp-027, dp-028) carries everything a JSON Schema cannot reach: options[].value uniqueness (no uniqueness-by-sub-property keyword exists), the Hub's submit-time refusal of an unanswerable schema — including the negative, that no message is created — and the whole allow_edit/edited behavior, which is cross-field by nature: it relates a resolve value to the originating request's options, which no single document can express.

One vector is deliberately marked valid despite pinning a rule: sv-071 submits two options sharing a value with different labels, which the schema accepts and which a conformant Hub MUST reject. It exists so the boundary of the schema's reach is recorded in the vector set itself, rather than left for a reader to infer from a green run — a passing suite must never be mistakable for proof that the uniqueness rule is enforced.

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.6/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:

  1. Verify the §9.7 detached signature against the inbound_signed_context it reconstructs — recomputing payload_sha256 from the directive it received, rejecting a t outside the replay window, and rejecting a replayed jti (from a jti cache with TTL ≥ the window, §9.7). It MUST treat the directive as untrusted until verified, and MUST also validate the directive's shape against inbound-message.schema.json — because payload_sha256 binds only the content fields, an on-path injector (or a buggy Hub) can append a forbidden request/action/state field 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).
  2. Confirm the addressee. After verifying, the agent MUST confirm to is its own agent:<id> and reject the directive otherwise. The signature binds to, so a valid signature proves the Hub intended a specific addressee — but only the recipient checking to against its own identity stops a directive validly signed for agent:X from being acted on by agent: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.
  3. Deduplicate on the directive id and act at most once — because at-least-once delivery (and any webhook/drain overlap) can present the same id more 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 the id before 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.
  4. 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 the id in 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:

13.5 Security considerations (inbound)

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:

14.1 The ack envelope

A JSON object; schema schema/v0.6/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)

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):

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:

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:

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.6/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.6/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

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.6/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 } }

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

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:

  1. 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.
  2. 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, ask-select-allow-edit (the v0.6 free-form answer) and its Response response-edited-answer, 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).