For implementers
The AMAP specification, at the wire level
AMAP defines a wire contract between a trusted runtime, which holds the mail credentials and applies policy, and an agent-side connector, which holds nothing credentialed. The two sides share one thing: a per-agent filesystem namespace. This page summarises what each side must do. The authority is the specification itself.
Source of truth. The AMAP Internet-Draft (v3.1.0 DRAFT; its source is draft/draft-amap.xml), the schemas and the fixtures are the normative specification. The peer-origin profile is in spec/peer-origin.md. Where this page and the specification disagree, the specification wins. A reference such as "§ Inbound" names a section of the Internet-Draft. The older spec/contract.md is frozen history, not the specification.
Three actors, one invariant
Runtime trusted
Holds the mail-send credentials, each agent's identity and the policy, and keeps the record. Reaches the mail provider and the volume.
Connector agent-side
One per agent. Holds nothing credentialed. Reaches only its own namespace on the volume and its agent's input and output.
Agent untrusted
Holds only what the connector surfaces. Reaches the connector and its own sandbox.
The load-bearing invariant (§ Architecture): no security property may depend on the agent or connector behaving. Enforcement is entirely runtime-side. A compromised agent or connector can at most write a well-formed request that the runtime then refuses. That is why the channel is a runtime-owned filesystem namespace rather than an API the agent could call: a file drop carries no capability.
The per-agent namespace
The runtime owns the tree and mounts each agent's namespace into that agent's sandbox (§ Volume Layout). A connector can reach only its own <agent-id>/. The runtime assigns that id, and attribution is the namespace a file arrived in, never an agent_id field inside it.
Two lanes, two trees
Mail and same-principal peer traffic arrive in separate trees: inbound/ for mail, and peer/ under the peer-origin profile. The tree a document is read from is the runtime's sole assertion of its origin class. A connector derives the class from the tree and never from any member of the document, and kind must equal the tree name (§ Volume Layout, tree rules; peer-origin §2). The two schemas cross-lock:
- a
kind: "peer"document found underinbound/fails the deliver-notice schema; - a
kind: "deliver"document found underpeer/fails the peer-notice schema.
Either way it is dropped. Write authority for each tree must be structurally enforced, never configuration the writer reads.
The documents
10 document types cross the seam. The rule for which are open and which are closed is direction, not taste (§ Versioning and Conformance, since v3.0.0):
- Runtime-authored documents are open. A consumer must ignore members it does not recognise, although emitting an undefined member is still a producer non-conformance.
- The agent-authored submit-request is closed, so an unrecognised member is a legible, per-request
rejected, never a silent drop.
| Document | Where | Written by | Schema | Envelope |
|---|---|---|---|---|
| Deliver-notice | inbound/notices/ | Runtime | deliver-notice.schema.json | Open |
| Peer-notice | peer/notices/ | Runtime | peer-notice.schema.json | Open |
| Inbound message (spooled body) | inbound/messages/ | Runtime | inbound-message.schema.json | Open |
| Result | outbound/results/ | Runtime | result.schema.json | Open |
| Peer directory | directory.json | Runtime | directory.schema.json | Open |
| Fleet roster | roster.json, where the deployment designates | Runtime | roster.schema.json | Open |
| Submit-request | outbound/req-<id>.json | Connector | submit-request.schema.json | Closed, at every level |
| Binding record | identity.json (volume root) | Runtime or provisioner | binding-record.schema.json | Closed, as an anti-secret-leak lint |
The binding record is closed for a different reason from the submit-request. Its closure catches a credential written into the file by mistake: the imap_password fixture tests exactly that. It is not a compatibility surface. The record is also informational, not authoritative. The agent can edit it, so it is never consulted for enforcement (§ Binding Record).
One trap to avoid: to is a property of the spooled inbound message, not of the deliver-notice's message object. Because the notice envelope is open, code that reads message.to off a notice will not be caught by the schema. The recipient list lives on the spool document (§ Inbound).
Outbound: the submit-request and its result
- The agent's only outbound act is an inert request file. It carries no send capability; the runtime enforces policy before anything leaves (§ Submit Request).
draft.tois required. Each recipient is one bare addr-spec. The runtime must evaluate recipient policy over exactly the recipient set it will transmit to, and must not transmit to any address the request does not name (§ Submit Request). It never adds or substitutes a recipient.- Identity is the runtime's. The runtime derives the message identity and From/Reply-To. The agent does not choose its sending identity (§ Submit Request).
- Header discipline. No agent-authored string may introduce a header boundary. CR, LF and NUL are rejected or stripped before composition (§ Submit Request).
- Exactly one result per consumed request, with
outcome∈ {accepted,queued_for_human,rejected} (§ Result).- Any policy miss (allowlist, rate, DLP hold, halt) is
queued_for_human: never a silent send, never a silent drop. acceptedmeans the provider accepted the message for relay, not that it was delivered.- A malformed request fails with
rejected. reason_codeexplains which path fired. It is an extensible vocabulary, and a consumer must never infer the outcome from it.
- Any policy miss (allowlist, rate, DLP hold, halt) is
Attachments and dispositions
An inbound attachment descriptor carries disposition ∈ {clean, stripped, quarantined, unscanned}. The disposition is the runtime's assertion, not a scanner verdict. How a runtime decides clean is runtime policy, but a runtime that cannot make the determination must not assert clean (§ Inbound).
- Bytes are published only for
cleandescriptors, and only then maycontent_refandsha256appear. content_refmust equal<notice_id>.attachments/<index>for the descriptor's own array position.- Sidecar blobs are named by 0-based ordinals.
filenameis display-only and never a path. - The declared
media_typeis display-only. The runtime sniffs the bytes, and the sniffed type governs policy.
Enforcement by absence
The runtime writes one notice per delivered message. Quarantined or unverified mail produces no notice at all: it is logged runtime-side and never seen by the agent (§ Inbound). A withheld body is simply not spooled, and a read against it returns "not resolved", with no fallback fetch.
Versioning
Two axes, not one:
- The specification's own version (currently v3.1.0 DRAFT) is SemVer for the document. Additive changes are MINOR, and a change that breaks an existing fixture is MAJOR.
- The wire
contract_version(currently"2") is what goes in every document and what a consumer checks.
Read contract_version before any schema validation, comparing majors as non-negative integers (§ Versioning and Conformance). An absent, lower or higher major is a version refusal that fails closed. It is a distinct, logged outcome, never folded into the malformed-document path. The refusal is asymmetric:
- Inbound: it is logged only.
- Outbound: a runtime refusing a submit-request answers with a
rejectedresult andreason_code: "unsupported_contract_version".
Because runtime-authored documents are open, adding an optional member to them is genuinely additive (MINOR). Adding any member to the submit-request is MAJOR.
Filesystem obligations
Many obligations are about the filesystem rather than the documents (§ Volume Layout):
- atomic writes;
- a commit sentinel: sidecar bytes are written before
req-<id>.json; - ingest-by-copy: the runtime validates and composes only from a private snapshot;
- write-side path discipline, three conjunctive requirements: pinned parent chains, no-follow while building, and an
O_EXCLfinal component; - read-side open discipline: no symlink following, non-blocking opens, regular-file checks on the open descriptor, and bounded reads;
- single writer and single drainer per namespace;
- connector tolerance of a read-only
inbound/tree.
Conformance, and what a green gate does not prove
The specification's Conformance section defines two conformance classes, runtime and connector. The peer-origin profile adds a sub-class for each. Claiming either without the fixture run and the prescribed operational check for that class is not conformance.
Run the gate with the Python standard library only. It needs no network access and no counterpart:
python3 fixtures/validate.py
# 119 fixtures checked, 0 unexpected.
Every valid/ fixture must pass and every invalid/ fixture must fail: 42 and 77 respectively. The schema is chosen by filename prefix, so a document you validate yourself needs its type chosen explicitly. The in-browser validator does that for you.
A green run does not prove the obligations a document validator cannot see. The NOT-CHECKED list in fixtures/validate.py is the authoritative inventory. It includes:
- sidecar existence, and
size_bytes/sha256matching the real bytes; - the rule that a filename is never a path;
- runtime-private staging;
- write ordering and atomicity;
- caps and allowlists;
- enforcement by absence;
- read-only-inbound tolerance;
- ingest-by-copy;
- both path disciplines;
- single writer and single drainer.
It also cannot tell you which document classes your producer never submitted. A conformance claim has to account for coverage as well as outcome.
Reference implementations
amap-router-local
The reference runtime, with its own test suite. On top of the seam it adds recipient policy, the two lanes, the reply ledger and delivery-status notices. Implementations in this family call a conforming runtime a router; the specification itself says "runtime" (§ Architecture).
amap-connector-claude
The Claude Code connector. It uses the Python standard library only, with zero third-party dependencies. It gives the agent ordinary MCP tools: a read-only inbox, and a submit tool that writes a gated request.
How changes are made today
These rules apply while the work lives in this repository. If the IETF takes it up, a working group will hold change control.
- A capability change starts in the specification. It lands as the Internet-Draft's prose,
schemas/andfixtures/in one change, before either side builds it. Neither a runtime nor a connector may invent a wire field locally. - The fixtures go in the same change. A field with no fixture is a field no implementer can prove they handle.
- Additive means MINOR; breaking an existing fixture means MAJOR, and a MAJOR is taken deliberately by both sides. A version mismatch fails closed.
- Some edits travel the other way. The standards editor edits the Internet-Draft directly, and those edits are canonical. A change in meaning then moves into the schemas and fixtures.
Read CONTRIBUTING.md and WORKFLOWS.md before opening a pull request. Report security problems in the specification privately, as described in SECURITY.md.
See also: validator · MCP, A2A and AMAP · glossary.