| Internet-Draft | AMMP | October 2026 |
| Rapp & Adams | Expires 24 April 2027 | [Page] |
The Agent Mediated Messaging Protocol (AMMP) is a wire contract, carried over a filesystem namespace, between an untrusted per-agent connector and a trusted, credentialed, store-and-forward messaging runtime. It gives a sandboxed AI agent the ability to send and receive messages (email, and under an optional profile, messages to other agents) through a runtime that does not trust it: every outbound message begins as an inert request file that the runtime copies into private staging, validates independently, and applies policy to before anything is transmitted, and every inbound message is authenticated and screened by the runtime before a notice describing it appears in the agent's namespace.¶
This document specifies the directory layout and its filesystem obligations — ownership, write and delete rights, and the path, symlink, and concurrency discipline a runtime observes; the JSON message shapes for submit requests, results, deliver notices, spooled message bodies, and an optional binding record; the correlation and versioning rules, including a direction-aware unknown-member tolerance rule; and two conformance classes. An optional profile gives agent-to-agent traffic within one principal its own tree, and specifies how a Runtime signs and verifies that traffic when it travels between hosts as mail.¶
AMMP is deliberately not a mail protocol: it neither defines nor replaces SMTP, IMAP, or provider-side infrastructure, and composes with them rather than displacing them. Its single design center is that no security property depends on the agent — or on the connector speaking for it — behaving correctly.¶
This document and the accompanying schemas and conformance fixtures are published under the Apache License 2.0.¶
This note is to be removed before publishing as an RFC.¶
Source for this draft, its schemas, its conformance fixtures and an issue tracker can be found at https://github.com/proofpoint/amap-spec.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 24 April 2027.¶
Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document. Code Components extracted from this document must include Revised BSD License text as described in Section 4.e of the Trust Legal Provisions and are provided without warranty as described in the Revised BSD License.¶
AI agents increasingly need to correspond, by email above all. They are tasked over it, they return results over it, and they correspond over it with people and with systems that speak no other protocol. An agent is nonetheless an untrusted process: it can be prompt-injected by the very content it is asked to read, it can be defective, and it can be compromised outright. Granting such a process email capability directly is dangerous along three concrete axes.¶
Inbound. Delivered mail is untrusted content routed directly into the agent's reasoning. A message body, a subject line, a sender display name, or an attachment filename can carry instructions the agent was never meant to follow. The delivery path is therefore an injection vector by construction, and no amount of care inside the agent removes that property.¶
Outbound. An agent that can send mail can exfiltrate anything it has read, to any address it chooses, and can do so under an identity that recipients associate with its operator. Outbound mail is simultaneously a data-loss channel and an impersonation channel.¶
Credentials and identity. Any mailbox credential, API token, or send endpoint held inside the agent's sandbox can be read, copied, or abused by whatever compromises the agent. Worse, an agent holding a credential has no identity of its own that a recipient — or its own operator — can verify independently of that credential.¶
Handing the agent mailbox credentials, or a mail-send API, makes the agent a trusted participant in the mail system. That is precisely the wrong posture for a component whose defining risk is that it may be compromised: the trust must be withdrawn exactly when it is least possible to detect that it should be. Wrapping the credential in a helper process that the agent calls does not change the analysis by itself, because a callable send capability is still a capability the agent holds; what matters is whether the capability crosses the boundary at all.¶
The common alternative is per-deployment glue: a bespoke script or service wired between one agent harness and one mail system. Such glue may even be correct, but it produces no reviewable contract, no interoperability, and no shared conformance bar. Nothing tells a second implementer what the first one guarantees, and nothing tells either of them what a third-party reviewer should check.¶
Adjacent agent protocols do not fill the gap, because they are solving a different problem: integration and interoperation among credentialed participants, rather than isolation of an untrusted one. Section 3.4 positions AMMP against that landscape in detail.¶
The premise is isolation. The agent runs in a sandbox, and the only path from that sandbox to its correspondence is the one this document specifies. Isolation is what makes an agent safe to connect, and it is also what makes it operable: a process that is contained can be replaced, replayed and audited as a unit. This document specifies what crosses the boundary, not the sandbox itself.¶
AMMP interposes a trusted runtime between the agent and the systems that carry its messages, holding each message before forwarding it. The runtime holds every credential, owns the agent's sending identity, and makes every policy decision. The agent interacts with the runtime only through a channel that carries no capability: it writes an inert request file into a directory, and the runtime — independently, on its own schedule, over its own private copy of those bytes — decides what, if anything, happens next. In the other direction the runtime authenticates and screens each message before writing a notice describing it into the agent's namespace, and withholds the bytes of anything it will not vouch for.¶
The payoff, which the rest of this document formalizes, is the load-bearing invariant:¶
No security property depends on the agent — or on the connector speaking for it — behaving correctly. Enforcement is entirely runtime-side. A compromised agent or connector can at most write a well-formed request that the runtime then refuses.¶
AMMP is the wire contract at exactly that trust boundary, and is owned by neither side of it. That ownership choice is what allows a runtime and a connector built by different parties, on different schedules, to interoperate and to co-evolve against a versioned contract with an offline conformance suite.¶
The medium is the boundary. An API, an RPC endpoint, or a message queue is a live capability: something the agent invokes, whose invocation is itself the grant. Its security therefore rests on authenticating and authorizing each call, on the client library behaving, and on the transport being what it claims to be. A file written into a directory grants nothing at all. It has no callee, no session, and no side effect until a separate, trusted process chooses to read it — at a time of that process's choosing, into memory of its own, under validation of its own. The runtime is free to ignore the file, to refuse it, to hold it for human review, or to consume it and answer with a verdict. Section 3.3 develops this point, along with the obligations it puts on the runtime in exchange.¶
Capability-free isolation. The channel available to an untrusted agent grants no send capability, no credential, and no reach outside its own namespace.¶
Vendor-neutral interoperability. A runtime and a connector built independently interoperate because both are built against this contract rather than against each other.¶
Versioned co-evolution with offline conformance. Either side proves conformance against a fixture suite with no counterpart present, and version changes are deliberate, legible, and fail closed rather than silent.¶
Composition with real email. Interactions remain federated, of-record, and human-inclusive, rather than confined to a closed agent-only channel.¶
Portability across agent runtimes. Nothing in the contract presumes a particular agent harness, language, or tool interface.¶
AMMP is not a mail transport. It neither defines nor replaces SMTP, IMAP, or any provider-side mail infrastructure. It describes what a runtime and a connector exchange locally; whatever the runtime does with a mail provider is out of scope.¶
AMMP is not agent-to-agent RPC. The core contract is local to one deployment's filesystem, between two components on either side of a single trust boundary. The optional peer-origin profile (Section 12) lets Agents of one principal write to one another, but as messages: between hosts they travel as ordinary mail, signed by the sending Runtime. The profile defines no call or return semantics, no discovery mechanism, and no transport of its own.¶
AMMP is not a policy language. What a runtime's policy decides — which recipients are permitted, which content is held for review, how a sender's standing is computed — is deliberately out of scope. The wire defines only the shapes of requests and verdicts, never the computation that produces them.¶
Every obligation in this document is grounded in what the contract actually provides. Where a capability is sketched but not in force, it is marked as such (Appendix K), and where a property is a runtime obligation rather than a wire-verifiable fact, this document says so (Section 14.14).¶
This section is to be removed before publishing as an RFC.¶
AMMP began as an implementation: a Runtime and Connectors built separately against a shared contract, with an offline conformance suite so that each side could be tested without the other. It has been exercised in deployments of several cooperating Agents from more than one vendor, but every deployment so far has run under a single operator. The authors bring it to the IETF for the things that implementation alone cannot supply:¶
Review. The design rests on a security argument (Section 3.2), and that argument needs review by people who did not make it, particularly from the mail, security and identity communities.¶
Interoperation. A contract owned by neither side is useful only if independent implementers build both sides. The authors are looking for implementers and for interoperation testing across organizations.¶
Collaborators. The work should be shaped by more than one organization, and by the people who operate mail at scale.¶
Alignment with related work. AMMP deliberately adds no new transport and no new identity scheme: email stays SMTP, and how an Agent's identity is established is left to work designed for that purpose. Where AMMP touches such work, it should follow it rather than invent its own.¶
Much of this document records why a rule is the way it is, because the reasoning is what a reviewer needs in order to disagree with it. None of it is offered as settled. The questions below are ones the authors already know are open; others will surely be raised, and every one of them deserves a fresh look now that more people are reading the work.¶
The wire version. The wire version starts at
"2" rather than "1", because implementations already emit and
check "2" (Section 13.1). Whether to reset it is a fair
question.¶
Registries. Whether the wire version, the Result
reason_code vocabulary, or the Control Notice's outcome and cause vocabularies should be
governed by IANA (Section 15).¶
The name of the attestation member. In this
document it is the identity stamp a Runtime applied to an outbound message
(Section 6.1). In IETF usage, attestation usually means remote
attestation of platform state, and the member may need another name.¶
Identity between Runtimes. The peer-origin profile signs traffic between hosts with Ed25519 keys configured per Runtime (Section 12.6.7). Whether that identity should instead be carried by an existing mechanism is open, and the candidates differ in what they would replace: a workload-identity system such as SPIFFE would replace the configured keys (distribution and attestation), while a standard signed envelope such as JWS with EdDSA (RFC 7515, RFC 8037) or COSE (RFC 9052) would replace the statement's own encoding, which is close to a detached JWS already. DKIM and ARC sign on behalf of a mail domain rather than a Runtime, so they neither bind a message to the issuing Runtime nor carry the statement's members.¶
Document structure. Whether the peer-origin profile belongs in this document or in a companion document.¶
Internationalized addresses. Addresses are restricted to a narrow ASCII addr-spec (Section 5.1), and internationalized addresses are out of scope for now.¶
A consumption signal. Whether a Connector should be able to tell its Runtime that a Notice has been handled (Appendix K).¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶
Every message defined by this document is a JSON text as defined by [RFC8259], encoded in UTF-8 [RFC3629]. The schemas in Appendix A through Appendix H are written in the JSON Schema 2020-12 dialect [JSON-SCHEMA-2020-12].¶
Long lines in the JSON examples and schemas in this document are folded using the single-backslash strategy of [RFC8792]. Each folded block begins with that strategy's header line, which is part of the folded content: it is what allows the original to be reconstituted mechanically, so it is kept inside the block.¶
Sections and subsections explicitly marked (informative) or (non-normative) carry no requirements; Section 3.4, Section 3.5, Appendix K, and Appendix L are the only such parts of this document.¶
Admitting Component — in the peer-origin profile, a Runtime component that admits messages from a transport not restricted to authorized peer senders; a mail deliverer is the usual example. It does not hold write authority over the peer tree (Section 12.3).¶
Agent — the untrusted process whose messaging capability this protocol mediates: email, and under the peer-origin profile, messages to other Agents. It holds no credential, has no send path of its own, and reaches the protocol only through its Connector. Nothing in this document places a requirement on the Agent, because no security property may depend on it.¶
Binding Record — the optional, informational artifact at the Volume root naming which mailbox the Volume is bound to (Section 9).¶
Connector — the per-Agent component that speaks the Agent-side half of this contract: it writes Submit Requests, reads Results and Notices, and surfaces them to its Agent in whatever form its harness uses. It is semi-trusted at best; for the purpose of every security property in this document it is treated as untrusted, and the requirements placed on it are well-formedness and non-crashing obligations, never obligations another party's safety rests on.¶
Control Notice — in the peer-origin profile, the signed report a receiving Runtime sends to a configured sending Runtime about a Peer Statement it did not place (Section 12.6.8).¶
Descriptor — one entry in an attachments[] array, describing an
attachment (its display filename, media type, size, and — depending on
direction — digest, Disposition, or content reference). A Descriptor is
metadata, not a path.¶
Disposition — the Runtime's assertion about an inbound attachment's
handling: clean, stripped, quarantined, or unscanned (Section 7.2).¶
Drop — to discard an artifact without surfacing it. Where this document says a consumer drops an artifact, the consumer MUST NOT surface the artifact to its Agent and MUST NOT act on it. A drop is a consumer's decision about one artifact; it is unrelated to the outbound tree, into which a Connector writes requests.¶
Fail Closed — to refuse to process an artifact rather than proceed on a partial or guessed interpretation of it.¶
Inbound Message — the Runtime-authored artifact carrying a delivered message's body text and headers, spooled under a Notice's identifier and fetched on demand (Section 7.3).¶
Lane — one class of traffic between an Agent and its
Runtime, or between two Agents: the mail lane (inbound/ and outbound
mail) and, under the peer-origin profile, the peer lane (peer/). The
Peer Directory states, per peer, which lanes are open.¶
Namespace — the per-Agent subtree <volume-root>/<agent-id>/, mounted
into that Agent's sandbox. It is the unit of isolation and the sole
authority for attributing an artifact to an Agent.¶
Notice (Deliver Notice) — the Runtime-authored artifact announcing that a message was delivered to the Agent's mailbox (Section 7). It is a pointer and summary, not the message.¶
Ordinal — the integer naming a Sidecar blob. It equals the index of the corresponding Descriptor in its array, and is the sole binding authority between a Descriptor and its bytes.¶
Origin Class — the class of origin a consumer assigns to an inbound
document, derived only from the Tree it was read from: mail for
inbound/, and same-principal peer origin for peer/
(Section 4.1, Section 12.3).¶
Peer Notice — the Runtime-authored artifact, in the peer-origin
profile, announcing a message from a same-principal Agent; the Deliver Notice
shape with kind "peer", placed under peer/notices/
(Section 12.2).¶
Peer Statement — the signed JSON object a sending Runtime attaches, in an
AMAP-Peer-Statement header field, to peer traffic it sends to another
host (Section 12.6.1).¶
Result — the Runtime-authored verdict for one consumed Submit Request (Section 6).¶
Runtime — the trusted, credentialed, Agent-agnostic, store-and-forward messaging component that speaks the other half: it holds the messaging credentials, owns each Agent's identity, makes every policy decision, and is the only party that transmits or receives messages. It holds each message before forwarding it: an outbound message is copied into private staging and checked before anything is transmitted, and an inbound message is screened before a Notice describing it appears.¶
Sandbox — the isolation boundary within which an Agent and its Connector run. The Runtime mounts the Agent's Namespace into it and nothing else of the Volume; no credential, send path or other messaging capability exists inside it. This document does not specify the sandbox mechanism: it presumes one, and every property it states holds only as well as the Sandbox holds.¶
Sidecar — attachment bytes stored beside a Submit Request or a Notice, in a directory named after that artifact, with each blob named by a 0-based ordinal.¶
Staging — Runtime-private storage outside the Volume, unreachable from any Agent's mount, into which the Runtime copies Agent-authored bytes before acting on them (Section 4.1).¶
Submit Request — the Connector-authored artifact requesting that a message be sent (Section 5). It is inert: it carries no capability and effects nothing by existing.¶
Tree — one of the Runtime-owned inbound-class subtrees of a
Namespace: inbound/, and, under the peer-origin profile,
peer/. Each Tree is an Origin Class.¶
Volume — the runtime-owned filesystem tree carrying this protocol,
containing one Namespace per Agent plus an optional Binding Record at its
root. Its mount path is a deployment choice; this document writes
<volume-root>/ for it and nothing in the protocol derives from its name.
No implementation may depend on that name or any other.¶
| Actor | Trust | Holds | Reaches |
|---|---|---|---|
| Runtime (host-side) | trusted | messaging credentials, Agent identity, policy, the record of what was sent | the mail provider, the Volume |
| Connector (per Agent) | semi-trusted, Agent-side | nothing credentialed | its own Namespace on the Volume; its Agent's input and output |
| Agent | untrusted | only what the Connector surfaces | the Connector; its sandbox |
The Runtime is trusted by construction: it is the party holding the credentials, and a compromise of it is a compromise of the messaging capability itself. The Connector is the interesting case. It runs Agent-side, is reachable by whatever compromises the Agent, and is therefore treated as untrusted for every property that protects the Runtime, other Agents, or the outside world. Requirements this document places on a Connector fall into two distinct classes, and the difference matters when reading them:¶
Well-formedness and liveness obligations — emit valid documents, tolerate a missing artifact, do not crash on hostile input. A Connector that violates one harms itself and its own Agent, not any other party.¶
Obligations that protect an Agent from its own inbox — the untrusted-content posture of Section 7. These are necessarily Connector-implemented, because the Connector is the only component on the Agent's side of the boundary, and they are stated as such rather than as properties the Runtime relies on.¶
No requirement in this document places a Runtime-side, other-Agent-side, or outside-world safety property in a Connector's hands. The Connector's obligation not to rewrite a published Submit Request might appear to be one; Section 4.1's ingest-by-copy rule removes that dependency, leaving the obligation as pure well-formedness.¶
Invariant. No security property may depend on the Agent or the 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.¶
Everything else in this document is downstream of that sentence. It is why the Agent's declared identity is a cross-check rather than an attribution source (Section 4.1); why the Runtime re-derives every path from data it controls rather than from a file's contents (Section 4.1); why recipient policy is evaluated Runtime-side over the exact recipient set that will be transmitted to (Section 5.1); why the digest an Agent supplies for its own attachment bytes is an integrity aid and not a trust boundary (Section 5.1); and why the absence of bytes, rather than a flag inside a document, is the access gate on inbound attachments (Section 7.2).¶
The invariant also bounds what this document can promise. A wire contract cannot make a Runtime honest, and a fixture suite cannot observe a filesystem. Section 14.14 states which properties are wire-verifiable and which are Runtime obligations that a green conformance run does not prove.¶
The Runtime mounts each Agent's Namespace into that Agent's sandbox, and the Agent's entire protocol surface is that directory. This is a deliberate choice of medium, with three consequences.¶
Writing a file carries no capability. It invokes nothing. There is no callee to authenticate, no session to hijack, no method whose mere reachability is a grant. The request exists as bytes until a trusted process elects to look at them. Compare an API or queue, where the act of enqueuing is itself the privileged act and every security property depends on the authorization check in front of it being correct and unbypassable.¶
Isolation is structural rather than enforced by policy. A Connector
scoped to <volume-root>/<agent-id>/ cannot name another Agent's Namespace
at all. Attribution follows from where a file appeared, not from what the
file says about itself, so spoofing attribution requires defeating the mount
rather than lying in a JSON member.¶
The medium's costs are real and are paid explicitly. A shared filesystem between a trusted and an untrusted party is itself an attack surface: symlinks, directory swaps, special files, hard links, and unbounded sizes are all available to the untrusted side, and every path-based check the trusted side performs is racy against a concurrent rename. This document therefore does not treat "it is just a directory" as a simplification. Section 4.1 states the path, open, copy, and concurrency discipline a Runtime observes in exchange for the medium's advantages, and Section 14 revisits each as a threat.¶
This subsection is informative. It contains no requirements and creates no dependency on any external protocol. Nothing here claims that AMMP replaces, subsumes, or competes with any protocol named below.¶
AMMP occupies a narrow position: it is the local seam at a single trust boundary, where one side holds every credential and the other holds none. The adjacent protocols in the agent ecosystem sit at different layers, and the common question of "why not use one of them here" has a specific answer in each case.¶
Model Context Protocol (MCP) [MCP] connects an agent host to tools and context through JSON-RPC servers. It is an integration interface: live capabilities are exposed across it and exercised by the agent through it. That makes MCP complementary to AMMP rather than an alternative. A Connector often surfaces AMMP's operations to its Agent through MCP, or through whatever tool interface its harness uses natively; AMMP is the capability-free seam sitting behind that surface. The contrast worth stating is exactly the one that motivates the two coexisting: an MCP-style interface grants live capability across the boundary it spans, while AMMP's channel deliberately grants none — the Agent-side surface can be as rich as the harness likes, because what crosses the trust boundary underneath it is an inert file. MCP originated at Anthropic.¶
Agent2Agent (A2A) [A2A] is networked agent-to-agent RPC between mutually authenticated services, with agent cards, tasks, and HTTP/SSE/JSON-RPC transport. It presumes both endpoints are credentialed participants that can authenticate to each other. AMMP's design center is the opposite: an untrusted agent that holds no credentials at all, with enforcement placed entirely on the trusted side of a local boundary. The two are orthogonal, and compose without friction — an agent that speaks A2A to its peers can use AMMP for its mail capability, and neither protocol needs to know about the other. A2A originated at Google and is hosted by the Linux Foundation.¶
The two protocols abbreviated "ACP" are not the same protocol, and the collision is worth disambiguating in a document a reviewer may arrive at with one of them in mind.¶
The Agent Communication Protocol (IBM/BeeAI) is of historical interest here: it merged into A2A under the Linux Foundation, announced in August 2025, with its repository archived [ACP-MERGER].¶
The Agent Client Protocol [ACP-CLIENT] is a distinct and currently active protocol at a different layer — an interface between a host or editor and an agent — originated by Zed Industries. It is unrelated to the merged Agent Communication Protocol and unrelated to AMMP.¶
This subsection is informative. It contains no requirements.¶
The Runtime is defined by its role, not as a single process. A Runtime may be built from several trusted components: for example, one that drains Agent Namespaces and applies policy, and another that holds the mail credentials and talks to the provider. Every obligation this document places on the Runtime binds the composite as seen from the Agent's Namespace. How the components divide the work, and how they communicate, is Runtime mechanism and out of scope. They may communicate by any means, for example SMTP [RFC5321] between hosts or LMTP [RFC2033] within one; this document specifies only the Agent boundary.¶
Some existing obligations are easy to lose when the work is divided:¶
exactly one Result per consumed request, including when an inner component fails or never answers (Section 6.1);¶
accepted only once the provider has accepted the message for relay
(Section 6.1);¶
the recipient set handed to the provider is exactly the set on which recipient policy was evaluated (Section 5);¶
nothing quarantined or unverified becomes a Notice (Section 7), and
clean is asserted only for bytes the publishing component has verified,
with every content_ref bound to the Notice it sits in (Section 7.2);¶
attribution is carried between components by Runtime configuration, never by anything the Agent wrote (Section 4.1); and¶
only the component facing the Agent's Namespace writes the Agent's lanes.¶
The Runtime owns the Volume and mounts each Agent's Namespace into that
Agent's sandbox. A Connector has access only within its own
<agent-id>/, and only to the paths below. Within that Namespace, write
access may be narrower still: the inbound/ subtree is Runtime-owned and MAY
be mounted read-only.¶
The Volume root's own name is a deployment choice and is not part of this
protocol — nothing derives from it, and no implementation may depend on
it. <volume-root>/ below stands for whatever a deployment calls it.¶
<volume-root>/
identity.json # runtime/provisioner: Binding Record (S9)
<agent-id>/
outbound/
req-<id>.json # agent -> runtime: Submit Request
req-<id>.attachments/ # agent -> runtime: Sidecar bytes,
# ordinals 0 1 2 ...
results/<id>.json # runtime -> agent: verdict for req <id>
processed/req-<id>.json # runtime: consumed requests
ext/<name>/ # connector-owned side channel a
# runtime MAY read by out-of-band
# agreement; <name> is a STABLE,
# opaque connector id
inbound/
notices/notice-<id>.json # runtime -> agent: delivered event
notices/<notice-id>.attachments/ # bytes asserted clean,
# ordinals 0 1 2 ... (S7.2)
notices/processed/ # OPTIONAL - connector archive,
# writable-inbound deployments only
messages/notice-<id>.json # runtime -> agent: full message
cursor.json # runtime: inbound poll cursor
peer/ # OPTIONAL - peer-origin profile:
# same layout as inbound/
directory.json # OPTIONAL - runtime: Peer Directory
audit/
log.jsonl # runtime: append-only action log
There is exactly one inbound Sidecar directory —
inbound/notices/<notice-id>.attachments/ — and both a Deliver Notice's and
an Inbound Message's content_ref resolve there. No second, message-specific
Sidecar directory exists.¶
Attribution by Namespace. <agent-id> is assigned by the Runtime, not
declared by the Agent. Attribution is the Namespace a file arrived in; an
agent_id member inside the file is a cross-check only. A Runtime MUST reject a Submit Request whose
agent_id, when present, does not match the Namespace it was found in, and
MUST NOT use that member to attribute the request. A Runtime MUST know the
<agent-id> of every Namespace it serves: from its own configuration
or, where its own layout carries it, from the Namespace's path, and MUST NOT
take it from anything the Agent writes. A Runtime that cannot name the Namespace in
which it found a Submit Request MUST reject that request rather than accept
its agent_id unchecked.¶
Isolation is structural. A Connector scoped to <agent-id>/ cannot read
another Agent's drafts or Notices.¶
All writes are atomic. Every artifact in the tree above is published by
writing a .tmp sibling and then renaming it into place (os.replace or the
equivalent). Readers MUST ignore *.tmp entries.¶
Single writer, single drainer. At any moment a Namespace has exactly one
Connector instance writing outbound/ and exactly one Runtime instance
draining it. Every concurrency-sensitive rule in this document — the atomic
.tmp-then-rename discipline above, the commit sentinel below, and the
derivation of req_id from max(pending, processed, results) + 1
(Section 5.1) — is sound only under that assumption and provides no mutual
exclusion of its own. Each side MUST ensure single-instance operation over a
Namespace by a mechanism outside this protocol, such as an OS-level lock
held for the process lifetime. A Runtime MUST NOT run two concurrent drains
of one Namespace, and a Connector MUST NOT run two concurrent writers into
one. Behavior under violation is unspecified: duplicated delivery, clobbered
published requests, and false verdicts are all reachable.¶
Filename identifiers are the authority for paths. An identifier taken
from a file's contents — a req_id member in a request body, for
instance — MUST NOT determine a write path. The Runtime derives identifiers
from the validated filename, on a restricted character set.¶
Write-side path discipline. Every write, rename, directory creation, or archive move into or within a Namespace MUST satisfy all three of the following conjunctively. Each closes a distinct gap the other two leave open, and none is sufficient alone.¶
(a) Full parent-chain pinning. The write resolves its full parent
chain through pinned directory descriptors from the Namespace root —
openat, mkdirat, renameat off a descriptor the Runtime opened once
per Namespace — and MUST NOT re-resolve a path string for the act itself. A
path-based containment check followed by a path-based act is a race, not a
check: the parent directory the check inspected and the parent directory
the act touches are not guaranteed to be the same object, because the
Agent can swap a parent directory for a symlink in between. Applying a
no-follow flag to the final path component alone does not close this gap
— it constrains only the last component, not a swapped parent — and
rename and directory-creation calls have no no-follow equivalent at all,
so a bare "writes do not follow symlinks" requirement is not sufficient on
its own.¶
(b) No-follow while building the chain. Each directory component that
goes into that parent chain MUST itself have been opened with
O_NOFOLLOW|O_DIRECTORY at the moment it was added to the pinned chain. A
descriptor counts as pinned only if the open that produced it refused to
follow a symlink at that step. Requirement (a) without this is equally
insufficient, and the gap is the symmetric one: nothing otherwise stops
the Runtime from acquiring a "pinned" descriptor by following an
Agent-planted symlink in the first place — the Agent replaces a Namespace
subdirectory with a symlink before the Runtime's once-per-Namespace open,
or before a later re-open after a restart — and a descriptor obtained that
way is pinned to the wrong object, so every write built off it inherits
the redirection no matter how faithfully (a) is followed from there.¶
(c) Non-following final component. The final path component MUST NOT
be followed if a symlink exists at that name: the open MUST carry
O_NOFOLLOW and MUST fail closed on ELOOP rather than writing through
whatever the name currently resolves to. Two cases differ in what else
they require, and both are covered:¶
A newly created file — the .tmp sibling the atomicity rule above
already requires, and any Sidecar blob — MUST additionally be created
with O_EXCL (O_WRONLY|O_CREAT|O_EXCL, never O_TRUNC in place of
O_EXCL), failing closed on EEXIST.¶
An append-mode or directory-creating write that has no .tmp
sibling — audit/log.jsonl is the case this document names — cannot
use O_EXCL, because the target legitimately already exists. It MUST
instead open with O_NOFOLLOW|O_APPEND|O_NONBLOCK (or use mkdirat
with the parent pinned per (b)), MUST NOT block on a special file, and
MUST verify through the resulting descriptor (fstat, never a pre-open
stat) that the object is a regular file it may append to; it SHOULD
refuse a target whose link count exceeds 1. The non-blocking open is
load-bearing and is not a performance concern: O_NOFOLLOW does not
apply to a FIFO — a FIFO is not a symlink — and opening one for writing
blocks until a reader appears, so without O_NONBLOCK the fstat is
never reached and an Agent that plants a FIFO at this name stalls the
drain indefinitely. The first branch's O_EXCL refuses that case on its
own; the second branch has no O_EXCL to lean on and states it
explicitly. Requiring O_EXCL in the second branch would forbid
appending to an existing log at all; requiring nothing there would leave
the one write this document names that has no .tmp sibling
unprotected.¶
Requirement (c) is needed in both of its branches because an Agent can
pre-plant a symlink at a filename the Runtime is about to write. req_id
is Agent-chosen, and every Runtime-written filename in the layout tree
above — results/<id>.json, the .tmp sibling of processed/req-<id>.json,
inbound/notices/notice-<id>.json, inbound/messages/notice-<id>.json,
audit/log.jsonl — is reachable to an Agent that can predict or observe
it. Absent (c), such a name gets its bytes written through the planted
symlink with the Runtime's own privileges, even though the parent chain
that reached the containing directory was itself correctly pinned under
(a) and (b).¶
This discipline governs every Runtime-side write named in the layout tree, not only the initial consumption of an Agent-authored artifact.¶
Read-side discipline. The same rule applied to reads. When the Runtime
opens any path the Agent can influence — every req-<id>.json, every
Sidecar ordinal, and, in a deployment that leaves inbound/ writable,
anything it later reads back from there — it MUST open without following
symbolic links at any component (per-component O_NOFOLLOW, or an
equivalently pinned directory-descriptor walk from the Namespace root,
matching the write-side rule above), MUST NOT block on a special file (a
non-blocking open), MUST verify that the opened object is a regular file
through the open descriptor (fstat and an S_ISREG test — never a
pre-open stat, which is a race), SHOULD refuse a file whose link count
exceeds 1, and MUST enforce a deployment-configured per-file size cap (a
bounded read) and a per-request Sidecar count cap. The too_large value in
the Result vocabulary (Section 6.1) presumes that cap: enforcing one is an
obligation, not an option.¶
A Connector's one Agent-influenceable read path — resolving a notice_id to
inbound/messages/notice-<id>.json — MUST be built only from a
pattern-validated notice_id (the schemas pin the pattern) and MUST confine
resolution to the Connector's own inbound/ tree (Section 7.3).¶
Sidecar ordinal binding. Sidecar blobs are named by Runtime-assigned
0-based ordinals equal to the Descriptor's array index, which is the sole
binding authority. Nothing else names a blob; a Descriptor's filename is
display-only, and MUST NOT be used as a path.¶
Commit sentinel. All Sidecar bytes are written — .tmp sibling, then
rename — before req-<id>.json; the JSON file's presence is the only
publish signal. The Runtime MUST verify that every referenced ordinal exists
and MUST re-verify size and digest before acting on the request, against the
snapshot it took at ingest (below), never against the live path.¶
Inbound Sidecar directories are keyed on the Runtime-minted notice
identifier. A Runtime MUST NOT name one after the provider message.id. The provider identifier is
external and is not character-set-safe, so using one as a path component would
let a sender influence where the Runtime writes. The same rule extends to
the body artifact: inbound/messages/notice-<id>.json MUST be keyed on the
notice identifier, and MUST NOT be keyed on message.id. Inbound bytes are published only
at inbound/notices/<notice-id>.attachments/<index>, the single Sidecar
directory named in the layout tree.¶
Runtime-private Staging lives outside the mounted Volume — at a path the Agent's mount cannot reach at all.¶
Ingest by copy. Before validating, evaluating policy on, or composing
from any Agent-authored artifact — req-<id>.json and every Sidecar
ordinal — the Runtime MUST copy the bytes into Runtime-private Staging and
MUST act only on that private copy. All checks — schema validation,
recipient policy, size and sha256 verification — and all message
composition MUST run over that snapshot, and the Runtime MUST NOT re-read
any Agent-writable path after a decision that depends on its content.
Verify-then-use against a live Agent-writable path is a race, not a check.¶
This obligation is what makes the Connector's "MUST NOT delete or rewrite a published request" (Section 4.2) a pure well-formedness rule: a Runtime MAY treat an observed post-publish change as malformed, but no safety property depends on the Connector honoring immutability — which is what the invariant of Section 3.2 requires. (This governs the properties that protect the Runtime, other Agents, and the outside world. Obligations that protect an Agent from its own inbox are necessarily Connector-implemented and are stated as such, not as properties the Runtime relies on a Connector for.)¶
Byte-absence is the gate. The Runtime MUST NOT place non-clean bytes in
an Agent's Namespace. content_ref is the Runtime's access grant, present
only for bytes it has published as clean (Section 7.2).¶
Tree rules. Every Runtime-owned inbound-class tree is an origin
class: a consumer MUST derive the class of a document from the tree it was
read from, and MUST NOT derive it from any member of the document. notice_id uniqueness
is per tree, and where a tree has more than one writer each MUST mint so
that cross-writer collision is negligible — at least 128 bits of entropy
under the identifier character set, or a writer-specific prefix — and a
consumer keying state across trees MUST include the tree in that key.
content_ref resolves only within the notices/ directory of the tree the
enclosing document was read from. Write authority per tree is distinct and
MUST be structurally enforced; it MUST NOT depend on configuration the writer
itself reads. inbound/ is the one tree the core protocol defines; a
profile, such as the peer-origin profile of Section 12, MAY
define others.¶
Inbound is Runtime-owned, and read-only is a valid posture. The
inbound/ tree is Runtime-owned end to end. A Runtime MAY expose it to the
sandbox read-only — for instance as a read-only mount, which is the
mechanism that actually makes a delivered Notice unforgeable, since file
permission bits do not bind a same-uid Agent, which can change a mode it
owns. A Connector MUST NOT require write access anywhere under inbound/:
it MUST start, relay Notices, and resolve bodies when every write, rename,
and directory creation under inbound/ fails. inbound/notices/processed/
is OPTIONAL — a conventional archive location a Connector MAY use only
where a deployment leaves inbound writable; nothing on either side may
depend on its existence or its contents.¶
Consumption tracking is Connector-private. How a Connector records which
Notices it has already relayed to its Agent is Connector-private state,
outside this protocol. Such state lives in the Connector's own storage
outside the Volume, or in Connector-owned paths under outbound/ not
enumerated in the layout tree — which MUST NOT use the req- prefix,
reserved for Submit Requests and their Sidecars, so that a Runtime's scan of
outbound/ never encounters Connector-private state. Either way, the
Runtime neither reads nor relies on it. A seam-visible consumption signal is
deliberately not specified by this document; Appendix K records the
reasoning.¶
Outbound extension space. outbound/ext/<name>/ is a
Connector-owned side channel, one directory per <name>, that a Runtime MAY
read by out-of-band agreement. <name> is a stable Connector id: the
Connector chooses it once, and it MUST NOT change when the Connector's
repository, package, or deployment is renamed or moved. A Runtime that reads
the directory MUST treat <name> as opaque — it is an identifier, never a
name to be parsed, resolved, or matched against a repository or path.
Because it is also a path segment in an Agent-writable tree, <name> MUST
be a single path segment of 1 to 64 characters drawn from lowercase ASCII
letters, digits, hyphen and underscore, beginning with a letter or digit, so
that it names the same directory on case-sensitive and case-insensitive
filesystems alike. This
document specifies no shape for anything under it; it lies outside the
req- prefix the Runtime scans, and nothing security-relevant may be built
on it. Its first use is a Connector's per-Notice outcome file, written
by the receiving Agent's own uid and therefore forgeable by construction. An
outcome is a statement by the receiving side about what it did with a
Notice; a party MUST NOT treat an outcome as proof of delivery or of
non-delivery, and the Runtime's own delivery record is authoritative over it
(Section 14.15). There is deliberately no inbound extension space: an
inbound tree is a Runtime assertion about origin, and a Connector cannot
know what a vendor-named assertion means.¶
This table names who may delete each artifact. It is permissive: a party exercising a right it already effectively had — a Runtime deleting from its own Runtime-owned tree, for instance — changes nothing about what conformant behavior looks like.¶
| Artifact | Written by | May be deleted by | Basis |
|---|---|---|---|
outbound/req-<id>.json (pending) and req-<id>.attachments/
|
Connector | Runtime | Consumption — the move to processed/, or deletion once the Result is written. The Connector MUST NOT delete or rewrite a published request; the JSON file's presence is the commit signal, and un-publishing is unspecified. |
outbound/processed/req-<id>.json
|
Runtime | Runtime at will; Connector after reading the matching Result | The Runtime's own archive. Caveat: Section 5.1 derives req_id from max(pending, processed, results) + 1, so a Connector that deletes here (or in results/) MUST preserve req_id monotonicity by other durable means, such as its own counter. Correspondingly, a Runtime MUST NOT rely on processed/ contents as durable state (for deduplication, say) — the Connector may delete there, and the Runtime's own record lives outside this protocol. |
outbound/results/<id>.json
|
Runtime | Connector, once read; Runtime by retention | No signal in this protocol tells the Runtime that a Connector has read a verdict, so Runtime-side deletion SHOULD be age-based and generous: a Result deleted before it is read is a lost verdict. |
inbound/notices/notice-<id>.json, inbound/messages/notice-<id>.json, and their .attachments/ Sidecar directories |
Runtime | Runtime only. Where a deployment leaves inbound writable, the Connector MAY move or delete per the optional processed/ convention, but MUST NOT depend on the ability. |
Runtime retention policy. Absent a seam-visible consumption signal (Appendix K), retention MUST be conservative and age-based. A Connector already tolerates the disappearance of these artifacts: a read against a missing one returns "not resolved" (Section 7.3). |
peer/ notices, messages and their .attachments/ Sidecar directories (peer-origin profile) |
Runtime | Runtime only, as for the inbound row above. | The same retention rule as the inbound row (Section 12.8). |
inbound/cursor.json
|
Runtime | Runtime | Internal state. |
audit/log.jsonl
|
Runtime | Runtime (rotation) | The Connector MUST NOT delete it. |
identity.json
|
Runtime or provisioner | Runtime or provisioner | Consumers already tolerate its absence (Section 9). |
outbound/ext/<name>/
|
Connector | Connector, at will; Runtime, only what its out-of-band agreement says it consumes (an outcome it has read, for example) | A Connector-owned side channel (Section 4.1). A Runtime MUST NOT delete the directory itself or anything else under it, and an operator reset is not consumption. |
directory.json
|
Runtime | Runtime | Advisory and Runtime-published (Section 10). It is not read back as authorisation, so deleting or replacing it carries no state. |
An Agent's only outbound action is writing an inert request file into its Namespace. The file carries no send capability; the Runtime enforces policy before anything leaves.¶
A Submit Request is written to <volume-root>/<agent-id>/outbound/req-<id>.json
as a JSON text [RFC8259]. Its Sidecar bytes, if any, are written first,
into req-<id>.attachments/, one blob per Descriptor, named by that
Descriptor's 0-based array index (Section 4.1, commit sentinel).¶
NOTE: '\' line wrapping per RFC 8792
{
"contract_version": "2",
"req_id": "00000001",
"agent_id": "agent.research",
"ts": "2026-07-20T04:10:56Z",
"in_reply_to": "1721457600000000000-4242",
"draft": {
"to": ["owner@example.com"],
"cc": [],
"subject": "Run report",
"body_text": "The run finished; details below.",
"reply_to_message_id": null,
"attachments": [
{
"filename": "report.pdf",
"media_type": "application/pdf",
"size_bytes": 48213,
"sha256": "970e441b6c4ecd37abf2541e1276c680bdec8481cef9368ff\
1d010fd6b784b07"
}
]
}
}
The normative shape is Appendix A. The Submit Request envelope is
closed: additionalProperties is false at every level, and a Runtime
MUST reject a request carrying any unrecognized member. Section 13.1
explains why this direction is closed while the Runtime-authored artifacts
are open.¶
contract_version (REQUIRED) is the wire major version, the string
"2". A Runtime MUST check it before schema
validation and MUST fail closed on an absent, lower, or higher major
(Section 13.1).¶
req_id (REQUIRED) is monotonic per Namespace and MUST NOT be
reused for the life of the Namespace's outbound/ tree; derive it from
max(pending, processed, results) + 1. Reuse collides message identifiers
and triggers provider-side idempotency deduplication. req_id is pinned
to a filename-safe character set by the schema, but it is not the path
authority: the Runtime derives the path identifier from the validated
filename (Section 4.1).¶
agent_id (OPTIONAL), if present, MUST match the Namespace the
request was found in, and a Runtime MUST reject a mismatch. A Runtime MUST NOT
use it as the attribution source (Section 4.1).¶
ts (OPTIONAL) is the Connector's timestamp for the request. It is
Agent-authored and therefore advisory.¶
in_reply_to (OPTIONAL) correlates this request with prior inbound
work. It carries either a Runtime-minted notice_id or a mail message
identifier [RFC5322]. It is a hint from an untrusted party and MUST
NOT by itself widen what the request is permitted to do — the same
posture Section 8 fixes for task_id.¶
The Runtime derives the message identity and the From/Reply-To of the
composed message itself; identity is Runtime-owned, and the Agent does
not choose its sending identity.¶
draft.to is REQUIRED and carries at least one
recipient; draft.cc is OPTIONAL and MAY be empty.
draft.subject and draft.body_text are REQUIRED and MAY be
empty strings. body_text is plain text; this document defines no
alternative body representation.¶
draft.reply_to_message_id (OPTIONAL, nullable) names the message
this draft replies to, as an [RFC5322] message identifier. It is
composed directly into an In-Reply-To: header, which is why it carries a
control-character pattern in the schema (below).¶
Recipient policy is a Runtime decision. The Agent's chosen draft.to
and draft.cc are not inherently trusted. Allowlisting,
task-counterpart binding, and any other recipient rule are evaluated
Runtime-side. A populated allowlist plus a fresh recipient is an
exfiltration surface that the Runtime, not the Connector, must close.¶
Recipient syntax and header-injection discipline. Each draft.to and
draft.cc element is exactly one bare addr-spec: no display name,
address list, quoted local part, comment, IP literal, or folding
whitespace. The schema pins the pattern. This profile is deliberately
narrower than [RFC5322], because those excluded forms are exactly what
makes allowlist evaluation non-mechanical; internationalized (EAI/SMTPUTF8)
addresses are out of scope for now and may be added later. A
Runtime MUST reject — with a top-level rejected Result — a request whose
recipient entries do not parse under this profile, MUST evaluate recipient
policy over exactly the recipient set it will transmit to, and MUST NOT
transmit to any address not present in draft.to or draft.cc.¶
Separately, a Runtime MUST NOT allow any Agent-authored string —
subject, a recipient entry, an attachment filename, an attachment
media_type, draft.reply_to_message_id, in_reply_to, agent_id,
ts, or body_text where it abuts headers — to introduce a header
boundary in the composed message. CR, LF, and NUL MUST be rejected or
stripped before composition even where schema validation was bypassed
upstream: the schema-level control-character patterns on subject,
filename, media_type, reply_to_message_id, in_reply_to,
agent_id, and ts are the first line of defense, not the only one. Two
of those fields are easily overlooked: reply_to_message_id and media_type are composed
directly into headers — In-Reply-To: and a MIME part's Content-Type:
[RFC2045] respectively — rather than into a body or a display field.¶
draft.attachments[] (OPTIONAL) is an array of Descriptors, each
carrying filename, media_type, size_bytes, and sha256.
size_bytes and sha256 are REQUIRED on every Descriptor, computed
by the Connector over the decoded bytes it wrote, with sha256 in
lowercase hexadecimal.¶
There is no outbound content_ref. The Runtime re-derives each
Sidecar's path from req-<id> plus the Descriptor's array index, which is
the sole binding authority; an Agent-supplied path string would be pure
disagreement and attack surface, so it is absent from the schema entirely
and additionalProperties: false rejects it. attachments: [] is valid
and is equivalent to the member being absent; the Sidecar directory MUST
then be absent.¶
sha256 here is an integrity and deduplication aid, not an outbound
trust boundary: the Agent controls both the bytes and the hash, so a
mismatch catches corruption, never a hostile Agent. Its value to the
Runtime is that it detects an inconsistent request, not that it
authenticates one.¶
A Result is the Runtime's verdict for one consumed Submit Request, written
to <volume-root>/<agent-id>/outbound/results/<id>.json.¶
{
"contract_version": "2",
"req_id": "00000001",
"outcome": "accepted",
"detail": null,
"reason_code": "allowlisted",
"ts": "2026-08-09T19:14:00Z",
"recipients": ["owner@example.com"],
"message_id": "<20260809191400.1@example.org>",
"job_id": null,
"attestation": {
"form": "self-stamp",
"token": "selfstamp.v1.abcdef",
"claims": {
"agent": "agent.research",
"acts_for": "owner@example.com",
"issued_at": "2026-08-09T19:14:00Z"
}
},
"attachments": [
{ "index": 0, "outcome": "accepted", "detail": null }
]
}
The normative shape is Appendix B. The Result envelope is open: a consumer MUST tolerate members it does not recognize, at every level (Section 13.1).¶
The Runtime writes exactly one Result per Submit Request it consumes. A request the Runtime never consumes gets no Result, and no signal in this protocol distinguishes "not yet" from "never."¶
outcome (REQUIRED) is one of accepted, queued_for_human, or
rejected. This is a closed vocabulary: it gates which processing
path a document takes, so a consumer cannot safely treat an unrecognized
value as opaque, and introducing a new value is a major change
(Section 13.1).¶
Any policy miss — allowlist, rate limit, DLP hold, halt — MUST produce
queued_for_human. A Runtime MUST NOT send such a request, and MUST NOT
drop it without a Result.¶
accepted means the provider accepted the message for relay.
Downstream delivery or quarantine is out of the Runtime's hands and is
surfaced later via an inbound delivery-status notification, if any.¶
detail (OPTIONAL, nullable) is human-readable text. It carries no
machine semantics.¶
reason_code (OPTIONAL) records which policy path authorized or
refused the request; human text stays in detail. A Runtime SHOULD
set a reason on every Result, successes included. The suggested
vocabulary — recipient_bound, allowlisted, no_verified_counterparty,
recipient_not_allowlisted, dlp_hold, no_screener, halted,
rate_limited, unsupported_contract_version — is extensible and not
enum-locked in the schema. Two consumer obligations follow: a consumer
MUST tolerate a reason_code it does not recognize, treating it as opaque
and never as an error, and a consumer MUST NOT infer the outcome from
it. outcome is the sole authoritative verdict; reason_code only
explains which path produced it, and a Runtime may add codes at any time
without a protocol change. unsupported_contract_version is the code a
Runtime sets on the rejected Result it writes for a version-refused
Submit Request (Section 13.1).¶
ts (OPTIONAL) is the Runtime-set, of-record time of the verdict, in
ISO-8601 form.¶
recipients (OPTIONAL) records the recipient set the Runtime
actually transmitted to.¶
message_id (OPTIONAL, nullable) is the [RFC5322] message
identifier the Runtime assigned to the composed message.¶
job_id (OPTIONAL, nullable) is a provider-side job identifier,
opaque to this protocol.¶
attestation (OPTIONAL) is the identity stamp the Runtime applied to
the outbound message. Its form is self-stamp or verifiable,
distinguishing a stamp a stranger cannot verify from one that can be
verified independently. A Connector surfaces an attestation, and does not
verify it; verification is a Runtime concern.¶
attachments[] (OPTIONAL) carries one diagnostic entry per submitted
attachment: index, an outcome of accepted, dlp_held,
rejected_type, or too_large, and an optional detail. index MUST
equal the entry's own position in the array — contiguous, unique, and
covering 0 through n-1 exactly once. Array position is the binding
authority for attachments in both directions (Section 4.1). This
constraint is not expressible in JSON Schema and is enforced by the
conformance gate as a named post-check rather than by the schema
(Appendix J).¶
Two paths, kept apart. A malformed attachment — a size or
digest mismatch, a missing or extra Sidecar, a duplicate index — is not a
policy decision: it fails the whole request with a top-level rejected.
A policy hold — DLP, a disallowed type, a size cap, including a
fail-closed hold when no screener is available — is queued_for_human.
Per-attachment outcomes are diagnostic detail under one governing
top-level outcome, and a Runtime MUST NOT send part of a request on their
strength.¶
The Runtime writes one Deliver Notice per delivered message. Quarantined or unverified mail produces no Notice: a Runtime MUST NOT write a Notice for it, and logs it on its own side instead. The Notice is a pointer and summary; the body is fetched on demand from a separate spooled artifact (Section 7.3) and is untrusted content.¶
{
"contract_version": "2",
"notice_id": "1721457600000000000-4242",
"ts": "2026-07-20T04:00:00Z",
"kind": "deliver",
"message": {
"id": "42",
"from": "Alex Doe <owner@example.com>",
"subject": "status?",
"preview": "hey, how is the run going",
"mailbox": "inbox",
"task_id": "0f1e2d3c4b5a6978",
"thread_id": "T-9182",
"in_reply_to": "<20260719120000.7@example.com>",
"references": ["<20260718090000.3@example.com>"],
"sender_standing": "known",
"provenance": "external",
"verdict": {
"auth": "dmarc-pass",
"screen": "clean",
"entitlement": "task-counterparty"
},
"attachments": [
{
"filename": "invoice.pdf",
"media_type": "application/pdf",
"size_bytes": 12345,
"disposition": "unscanned"
}
]
}
}
The normative shape is Appendix C. The Deliver Notice envelope is open: a consumer MUST tolerate members it does not recognize, at every level (Section 13.1).¶
The drop rule. A Connector relays only deliver Notices. It MUST drop —
not surface — a Notice that fails either of two independent checks,
treating a failure of either one alone as sufficient grounds to drop:¶
Schema failure. The Notice fails validation against the
deliver-notice schema (Appendix C) for any reason other than an
unrecognized member. That single formulation covers a kind other than
deliver, a missing required member, an enum violation on a closed
vocabulary, a pattern violation (including a traversal or character-set
violation in notice_id, or a shape violation in content_ref's own
pattern), a conditional (allOf / if-then) violation, and a type
violation alike, with no need to enumerate schema-failure categories
separately. A Connector MUST NOT drop a Notice because it carries an unrecognized member
(Section 13.1); that is the one carve-out from "fails schema
validation."¶
The content_ref index and prefix binding. A Notice can pass check 1
— every member individually well-shaped — and still carry an attachment
Descriptor whose content_ref does not equal
<notice_id>.attachments/<i> for its own 0-based array position i
(Section 7.2): a mismatched trailing index, or a leading notice_id
prefix pointing into a different Notice's Sidecar directory. A
Connector MUST run this check independently of schema validation and MUST
drop the Notice on failure exactly as it would a schema failure.¶
This is not a closure over schema validation. Check 2 binds two members
of the same document — a Descriptor's content_ref against the enclosing
document's own notice_id and against the Descriptor's own array position —
against each other, which is not expressible in the JSON Schema vocabulary
these schemas use [JSON-SCHEMA-2020-12]. It therefore cannot be folded
into "fails schema validation" no matter how the schema is phrased: it is a
second, independent condition, not a special case of the first. The
conformance suite enforces it as a named post-check and pins it with a
fixture that is schema-valid on its own and fails only this check
(Appendix J).¶
A version refusal — an absent or non-matching contract_version major —
is explicitly not part of this drop path. It is handled first and
separately (Section 13.1) as a distinct, logged outcome: a Connector checks
the version before it ever reaches schema validation, so a version-refused
Notice never falls into the drop rule at all.¶
A document with kind: "peer" found under inbound/notices/ fails check 1
against this schema, and is dropped the same way (Appendix F, the
peer-origin profile's cross-lock).¶
Field obligations.¶
contract_version, notice_id, ts, kind, and
message are REQUIRED. notice_id is Runtime-minted and is pinned by
the schema to a character set that is safe as a path component and as a
content_ref prefix.¶
Required members of message are id, from, subject, preview, and
mailbox. The rest is enrichment (Section 13.2).¶
message.id is the provider's message identifier. It is external
and not character-set-safe, and MUST NOT be used as a path component
anywhere (Section 4.1). It is the mail store's identifier for the
message (an IMAP UID, for example), not the [RFC5322] Message-ID; an
Agent replies to a delivered message by naming its notice identifier in
in_reply_to (Section 5).¶
message.from is attacker-controlled display data. Its display-name
half is chosen freely by the sender — "security@yourbank.example
<attacker@evil.example>" is a legal value — and it MUST NOT be used for
any trust decision, nor rendered to a human or to an Agent as verified
identity. Verified sender identity is only ever what the Runtime
summarizes into sender_standing and verdict; it is not the from
string itself. The one exception in this protocol is the message.from of a
Peer Notice, which the Runtime asserts (Section 12.4).¶
Presentation obligations. A Connector that presents a message body to
its Agent MUST convey two things, whatever words it uses: quoted or
forwarded material inside the body carries no authority of its own — a
message's authority extends to what its author wrote, never to what they
relayed — and that an attachment's disposition is the Runtime's assertion
about handling and not a scanner verdict, so a Connector MUST NOT imply that
bytes have been screened and MUST NOT contradict the per-attachment status
the Runtime published. A Connector MUST also convey that an attachment's
filename and media_type are sender-chosen display strings on the mail
tree; on a tree whose sender the Runtime authenticated it SHOULD. On the mail
tree a Connector MUST additionally convey that the sender is not
authenticated beyond whatever standing the Runtime has already summarized
— carried in sender_standing and verdict, named here as the locus and
not as vocabulary to repeat to a reader — and that message.from is
sender-chosen text. These are
obligations about substance, not vocabulary: a Connector satisfies them
with any wording its reader can act on without consulting this document,
and they MUST be satisfiable by a literal string the Connector carries,
requiring no lookup or computation when a body is presented.¶
The untrusted-content posture covers every sender-originated string in
the Notice and in the spooled Inbound Message: message.from, subject,
preview, body_text, references, attachment filename and
media_type, and the Inbound Message's from, to, cc, and subject.
This is not limited to the members called out individually here.¶
message.sender_standing (OPTIONAL) is an open, advisory
vocabulary — known, agent-tier, first-contact, read-only, and
others. How the values are computed is Runtime policy and out of scope;
the wire fixes only today's known vocabulary, so that a Connector can tell
its Agent "stranger," "agent," or "needs sign-off." A Connector MUST
tolerate a value outside this list, treating it as opaque — never as
grounds to drop the Notice — and a Runtime MAY add a value as a minor
change (Section 13.1).¶
message.provenance (OPTIONAL) classifies the message source —
external, internal, and others — so that a Runtime can route
provenance-labelled screening. It is advisory metadata, like verdict, and a
Connector MUST NOT present it as license for an Agent to obey body content. It is likewise an
open, advisory vocabulary: a Connector MUST tolerate a value outside
this list, treating it as opaque, and MUST NOT drop the Notice on its
account; a Runtime MAY add a value as a minor change.¶
message.verdict (OPTIONAL) carries the upstream authentication,
screening, and entitlement results the Runtime consumed. A Connector
treats it as advisory metadata, and MUST NOT present it as license for an Agent
to obey body content. The object is open: unrecognized members inside it are tolerated,
as they are everywhere else in this artifact.¶
message.thread_id, message.in_reply_to, and
message.references (all OPTIONAL) carry provider and [RFC5322]
threading identity. They grant correlation, and a consumer MUST NOT treat them as
grounds for trust (Section 8).¶
message.task_id (OPTIONAL) is Runtime-minted; Section 8 states the
obligations on it.¶
Direction and openness. This artifact is Runtime-authored, and its
envelope is open at every level: a Connector MUST tolerate a member it does
not recognize, in message or at the top level, and MUST NOT drop the
Notice on that account. Emitting an unrecognized member remains Runtime
non-conformance (Section 13.1).¶
message.attachments[] (OPTIONAL) is an array of Descriptors carrying
filename, media_type, and size_bytes — all REQUIRED — plus a REQUIRED
disposition, and OPTIONAL sha256 and content_ref members gated on it.¶
disposition is the Runtime's assertion, not a scanner verdict. Its
values are clean, stripped, quarantined, and unscanned, and the
vocabulary is closed: it gates byte publication, so a new value would be
a behavioral fork a consumer cannot treat as opaque, and introducing one is
a major change (Section 13.1). On what basis a Runtime asserts clean is
deliberately unspecified — a screening pipeline's pass in one deployment, a
closed operator-supervised topology in another. That is Runtime policy and
out of scope for the wire, the same posture sender_standing takes: the
wire fixes the vocabulary, never the computation. The Runtime is trusted by
construction (Section 3.1); the load-bearing invariant is that the Agent
is not.¶
The counterpart obligation is equally plain: a Runtime that cannot make
the determination MUST NOT assert clean. It emits unscanned — or
stripped or quarantined where it made that decision — and publishes no
bytes.¶
Byte publication and the access grant. A Runtime MAY publish an
attachment's bytes at inbound/notices/<notice-id>.attachments/<index>
only for a Descriptor it asserts disposition == "clean", and only then
MAY it emit content_ref (a path of the form
<notice-id>.attachments/<index>) and sha256. Byte-absence remains the
gate (Section 4.1) for anything not asserted clean. Exposing sha256 on a
withheld item would be a confirmation oracle for no integrity value, which
is why it is clean-gated too. Both conditionals are expressed in the schema.¶
The content_ref binding. content_ref's leading notice_id prefix
MUST equal the enclosing document's own notice_id, and its trailing
<index> MUST equal the Descriptor's own 0-based array index. The array
index remains the sole binding authority (Section 4.1), and a consumer that
resolves bytes MUST derive the path from the enclosing notice_id and the
index, and MUST NOT use the content_ref string's own text as the path,
since a Descriptor could otherwise point into another Notice's Sidecar
directory. This is a wire-shaped, document-level obligation, enforced by the
conformance gate as a named post-check (Appendix J) in the same way the
Result-side attachments[i].index == i binding is.¶
A grant, not a directive. A consumer MUST tolerate a Descriptor whose
bytes it does not resolve — relaying the Notice as usual — and MUST NOT
treat the presence of content_ref as a requirement to fetch. A Connector
that never dereferences content_ref remains fully conformant. A Connector
MUST resolve published bytes only within its own Namespace's
inbound/notices/ tree and MUST reject any content_ref that does not
match the pinned pattern — the same confinement Section 4.1's read-side
discipline requires generally.¶
filename and media_type here are attacker-controlled, untrusted
display strings. The untrusted-content posture extends to attachment
Descriptors: a quarantined file named to look like an instruction is still
an injection payload even with its bytes withheld. A clean assertion
covers the bytes' screening disposition, never the truthfulness of these
strings.¶
Inbound size_bytes is provider-declared and therefore advisory and
untrusted — it comes from the provider's structure report, and is not
Runtime-verified the way outbound size_bytes is.¶
MIME edges. filename is the [RFC2047] / [RFC2231]-decoded,
Runtime-sanitized display string. An "attachment" is any part carrying
Content-Disposition: attachment [RFC2183], or any named or non-inline
text part — inline images and multipart/related parts count, which is the
conservative reading of [RFC2045] and [RFC2046]. The declared
media_type is display-only: the Runtime sniffs the bytes, and the sniffed
type governs policy on the outbound gate.¶
The Runtime spools the delivered body at
<volume-root>/<agent-id>/inbound/messages/notice-<id>.json, keyed on the
Runtime-minted notice identifier — never on the provider message.id, for
the reason given in Section 4.1.¶
{
"contract_version": "2",
"notice_id": "1721457600000000000-4242",
"id": "42",
"date": "Mon, 20 Jul 2026 04:00:00 +0000",
"from": "Alex Doe <owner@example.com>",
"to": "agent@example.org",
"subject": "status?",
"body_text": "hey, how is the run going",
"attachments": [
{
"filename": "report.pdf",
"media_type": "application/pdf",
"size_bytes": 48213,
"disposition": "unscanned"
}
]
}
The normative shape is Appendix D. Required members are contract_version,
notice_id, and body_text; the rest — id, date, from, to, cc,
subject, and attachments[] — is enrichment (Section 13.2). Descriptors
here follow exactly the rules of Section 7.2, including the content_ref
binding and the clean gating. This artifact's envelope is open, like the
other Runtime-authored artifacts.¶
Resolution. A Connector MUST resolve a notice identifier to a
body from this artifact only, and MUST NOT fetch the body from the provider. The body is
untrusted content, the same posture as the rest of message.¶
Enforcement by absence. A body the Runtime withholds — because of screening or quarantine, or because the message was never delivered — is simply not spooled, and a read against a missing artifact returns "not resolved." There is no other signal and no fallback fetch. The same "not resolved" response also covers an artifact the Runtime later removes under its own retention policy (Section 4.2): from the Connector's side, retired and never-spooled are indistinguishable, and both are already-specified behavior.¶
The Runtime mints and owns task_id; a Connector only reads it, to
correlate a Notice with prior work. The minting and thread-resolution
algorithm is Runtime policy and out of scope. This protocol fixes three
security obligations on it.¶
task_id MUST be unguessable and Runtime-minted — an HMAC over the
thread root, for instance. It MUST NOT be a sequential or otherwise
guessable value, and it MUST NOT be a capability an inbound sender can
assert: a guessable task_id lets a sender who has seen one predict
others.¶
Matching a task_id, by itself, MUST NOT grant a message entry into
that task. Authoritative correlation is header threading plus
per-message verified sender identity. A subaddress or header token
supplied by an inbound sender is a hint only, and a Runtime MUST NOT treat
it as authoritative: it is attacker-controllable, and it is stripped by many gateways.¶
Threading grants correlation, not trust. A message that threads onto a known task is still identity-verified per message; it does not inherit the task's standing.¶
The Binding Record is written by the Runtime or provisioner at the
Volume root — <volume-root>/identity.json, a sibling of
<agent-id>/, not inside it — and names which mailbox this Volume is bound
to. Its normative shape is Appendix E.¶
The Binding Record names which mailbox a Volume is bound to; it is informational and not consulted for enforcement.¶
{
"contract_version": "2",
"agent_id": "agent-research",
"address": "agent@example.org",
"provisioned_ts": "2026-07-20T04:10:56Z"
}
Informational, not authoritative. The Volume is an Agent-writable filesystem, so a compromised Agent can edit or delete this file at will. Attribution and policy stay keyed on Runtime-side configuration (Section 4.1). This record grants nothing, and a Runtime MUST NOT consult it for enforcement.¶
Consumers MUST tolerate its absence, and MUST treat its contents as untrusted data: a reader MUST NOT crash, or change its protocol behavior, because of a malformed, oversized, or hostile file.¶
Its jobs are narrow by design: operator sanity — a human confirming which mailbox a Volume is bound to — a pre-start conflict check for whatever wrote it, catching for instance one Agent's credentials pointed at another Agent's Volume, and letting a Connector surface the Agent's own address back to its Agent.¶
The file is OPTIONAL (Section 13.2): nothing in this protocol requires a Runtime to write it or a Connector to read it, and both sides MUST work correctly with it absent.¶
Required members, if the file is present, are agent_id, address, and
provisioned_ts. label is OPTIONAL. contract_version is OPTIONAL
here, as it is on the Peer Directory (Section 10), because
the file is not a protocol artifact a consumer must parse to function; if
present it is pinned to "2".¶
This artifact's envelope is closed (additionalProperties: false),
for a different reason than the Submit Request's is: closure here is an
anti-secret-leak lint, catching a provisioner that writes credential
material into a file living on an Agent-readable Volume. It is not a
compatibility surface.¶
A Runtime MAY publish, at the Agent's Namespace root
(<agent-id>/directory.json, beside the lanes rather than inside one), a
per-instance projection of the authorisation graph naming which peers that
Agent may address.¶
{
"contract_version": "2",
"router": "amap.router@fleet.example",
"written_at": "2026-09-18T12:00:00Z",
"delegate_to": [
"bravo@fleet.example",
"charlie@fleet.example"
],
"mail_to": []
}
Advisory, not authoritative. The directory is the Runtime's most recent word about the graph, and grants nothing. A Connector MUST NOT treat an address's presence as a prediction that a submission to it will be accepted, nor its absence as a prediction of refusal: authorisation is evaluated by the Runtime at submission time, and the Result (Section 6) is the only thing that reports what happened. A consumer that reads the directory as a prediction is wrong even when the file is current.¶
A Runtime MUST NOT read directory.json back, for any purpose. It is
write-only from the Runtime's side: the graph is derived from policy, the
file is a projection of the graph, and nothing derives the graph from the
file. Enforcement remains the Runtime's own check against its graph, on every
message. The prohibition is on the direction rather than the purpose, because
the ways this decays — as an optimisation, a cache, a recovery source, or a
cross-check — all have innocent intent, and the last two turn a projection
into a source. A projection that can be read back becomes a cache; a cache
becomes a record; a record that disagrees with policy wins by accident.¶
Per-instance projection. Each Agent's directory carries only the edges that Agent is party to. It MUST NOT carry the set of peers who may address that Agent, which is an allowlist in all but name, and MUST NOT carry the Agent's own address — attribution is the Namespace a file arrived in (Section 4), so an address an Agent reads is a claim rather than an identity.¶
router is the publishing Runtime's own address and MUST NOT appear in
either list. Every address is one bare addr-spec (Section 5).¶
Both lists are always present, and empty when that lane is closed for this
Agent; mail_to is empty in a delegation-only deployment. Absent and empty
MUST NOT be conflated.¶
Written whole and atomically. A Runtime MUST NOT publish a newly adopted instance's address to others before it has adopted that instance itself.¶
A Runtime that publishes the directory MUST ensure the Agent can read it, and SHOULD expose it read-only. Publishing to a path the Agent's Namespace does not expose is not conformant: the write succeeds, the file is correct, and no Agent can read it — a failure that reports nothing on either side. Publishing is optional, so a Runtime that cannot make the file readable MUST NOT publish it; an unreadable directory is indistinguishable from an absent one to a consumer, and worse than an absent one to an operator, who has a file on disk that looks like it is working. The trap follows from the location: the file deliberately belongs to no lane, which is what keeps a delegation-only deployment from materialising a mail tree, and is exactly what places it outside an exposure built lane by lane.¶
written_at is the only staleness signal a consumer gets: an honest
marker, not a freshness guarantee.¶
Where the consumer is an Agent rather than a parser, these become
obligations to convey. A Connector that surfaces the directory to its Agent
instead of interpreting it satisfies the obligations above by stating them,
under the presentation rule in Section 7: the Agent is the party forming a
belief, so the Connector MUST convey that an address's presence is not a
prediction of acceptance, that the file may be absent, that an empty list and
an absent list differ, and that written_at is the only staleness signal
there is. A Connector with no directory parser does not thereby satisfy them
vacuously.¶
The file MAY be absent — before first adoption, on a Runtime that does not implement this section, or after a failed write — and a consumer MUST tolerate absence exactly as it tolerates a missing Binding Record (Section 9).¶
A Runtime that cannot write it keeps routing. The directory is advisory,
so a failed write loses a convenience, not the ability to enforce; failing
closed would convert an advisory artifact into a dependency. The Runtime MUST
log the failure. The Agent's only signal remains written_at going stale.¶
A Runtime MAY publish a fleet roster: a single file, written for the whole fleet rather than per instance, naming the instances the Runtime has admitted. The Peer Directory (Section 10) answers which peers an Agent may address; the roster answers only which instances exist. It is a separate artifact, and no obligation of Section 10 applies to it except where this section states one.¶
Existence is not authorisation. A member's presence says the instance exists. It does not say this Agent may address it, and it is not a prediction that a submission to it will be accepted: authorisation is evaluated by the Runtime at submission time, and the Result (Section 6) is the only thing that reports what happened. A Connector MUST NOT treat membership as authorisation or as a prediction of acceptance.¶
Not a source of identity. The roster is fleet-wide, so the reader's own address MAY appear in it. A consumer MUST NOT derive its own identity from the roster and MUST NOT treat its own entry as a peer: attribution is the Namespace a file arrived in (Section 4), and an address read from a file is a claim rather than an identity.¶
Write-only from the Runtime's side. A Runtime MUST NOT read the roster back, for any purpose, for the reasons given for the directory in Section 10.¶
Written whole and atomically. A Runtime MUST NOT list a newly
admitted instance before it has adopted that instance itself.
router is the publishing Runtime's own address and MUST NOT appear in
members. Every address is one bare addr-spec.¶
Unknown is not empty. members is always present.
When the Runtime cannot determine membership, it MUST NOT write an empty
members list; it leaves the previous file in place to age.
written_at is the only staleness signal a consumer gets, and
interval_s, when present, is the Runtime's nominal rewrite interval to
judge it against. A consumer MUST treat an absent roster, or one it judges
stale, as unknown, and MUST NOT treat it as an empty fleet. Unknown is not unusable: an Agent
may still address a member of a stale roster, and the Runtime decides; what it
must not do is conclude that an instance is absent from the fleet. This
section fixes no freshness bound; how stale is stale is the consumer's
judgement, made against interval_s where it is present.¶
Readability is split between the parties that can observe it. A Runtime cannot observe a mount. It MUST publish the roster only into a location the deployment designates for it, and MUST NOT create that location. The deployment MUST expose that location read-only to each Agent it points at the roster, and SHOULD stop pointing Agents at the roster before, or together with, ceasing to expose it. A roster written where nothing is exposed is inert, not a violation: an Agent is only ever pointed at a roster its deployment exposes.¶
Conveyed, where the consumer is an Agent. Whatever points an Agent at the roster, whether a Connector that surfaces it or a deployment that tells the Agent where to read it, MUST convey that membership is neither authorisation nor a prediction of acceptance, that the roster is not the Agent's identity and its own entry is not a peer, and that an absent or stale roster means unknown. Stating these in text the Agent receives whenever it is pointed at the roster satisfies this; no tool is required.¶
The roster's shape is pinned by its schema (Appendix H)
and by the roster-* fixtures (Appendix J). The envelope is
Runtime-authored and therefore open (Section 13.1): a consumer
ignores members it does not recognize, and emitting one remains producer
non-conformance.¶
This section is an optional profile. It gives agent-to-agent traffic between
Agents of the same principal its own inbound tree, peer/, and states what
that tree asserts, who may write it, how peers are addressed, how a reply is
correlated with the message it answers, and how peer traffic is carried and
verified between hosts. A deployment that implements the profile is bound by
every requirement in this section; a deployment that does not is unaffected. No
schema or fixture outside the profile depends on it, and the wire
contract_version is the same for both.¶
The profile is optional because peer origin is needed by deployments in which Agents task one another, and by no deployment that only handles mail.¶
The tree is named for what it asserts, not for what it is used for:
peer/ asserts authenticated, same-principal peer origin, and the
document kind is peer. What a Connector does with a peer
message (delegation, review, notification) is the Connector's business.
Because the kind and the tree share a name, the rule that a document's
kind equals the name of the tree it was read from is mechanical.¶
The profile adds an origin class and reuses mail threading for correlation. It defines no call or return semantics, no method or operation member, and no status vocabulary for the work itself: a reply is prose authored by an Agent, with the posture of any other peer content. This keeps the profile on the mail side of the line drawn in Section 1.5.¶
The profile relies on two generic rules in Section 4.1: the
tree rules (every Runtime-owned inbound-class tree is an origin class,
derived from the tree and never from a member, with notice_id
uniqueness, content_ref confinement and write authority all per tree),
and the outbound extension space outbound/ext/<name>/,
where delivery outcomes are kept (Section 12.8). There
is deliberately no inbound extension space.¶
<agent-id>/
outbound/ # as in the Volume Layout
inbound/ # mail
notices/ messages/
peer/ # PROFILE: same layout, conventions
notices/notice-<id>.json # and Sidecar rules as inbound/
notices/<notice-id>.attachments/<i>
messages/notice-<id>.json
audit/
A sibling of inbound/, never a child. Nesting
the peer tree inside the mail tree would put the tree the mail deliverer must
not write inside the tree it must write, so the write-authority partition
(Section 12.3) would have to be carved out of a mount
instead of being one. As a sibling, the partition coincides with mount
granularity. Every obligation on inbound/ in Section 4
applies to peer/ unchanged: it is Runtime-owned; it MAY be mounted
read-only; a Connector MUST NOT require write access to it; only the Runtime
deletes from it; and its retention is conservative and age-based.¶
Its own schema. A Peer Notice is pinned by the peer-notice
schema (Appendix F): the Deliver Notice shape with kind
fixed to "peer" and message.mailbox fixed to
"peer", message.from pinned to a bare addr-spec
(Section 12.4), message.id,
message.in_reply_to and message.references[] pinned to the
notice_id character set (Section 12.5), and the
optional Runtime-asserted message.sender_exposure
(Section 12.7). Attachment Descriptors are exactly those
of Section 7.2. The body spool uses the Inbound Message schema
(Appendix D) unchanged. Because a Peer Notice appears only in a
tree that a mail-only consumer never reads, the closed kind
vocabulary of the Deliver Notice is untouched.¶
The cross-lock. A deliver document under
peer/notices/ fails the peer-notice schema, and a peer
document under inbound/notices/ fails the Deliver Notice schema. Both
are dropped under the rule of Section 7.1.¶
The placer mints notice_id. The component that
places a notice into a tree mints its notice_id. Where a tree has more
than one writer, each MUST mint so that a collision between writers is
negligible: at least 128 bits of entropy under the notice_id character
set, or a writer-specific prefix. A consumer that keys state across trees MUST
include the tree in the key. On a single host, a Runtime MAY make the
notice_id equal to the peer message id (Section 12.5).
Between hosts they differ, because the receiving Runtime mints the
notice_id and the message id is the sender's. A consumer MUST NOT
assume that they coincide.¶
content_ref is confined to its tree. Bytes resolve
only within the notices/ directory of the tree the enclosing document
was read from, recomputed from that document's notice_id and the
Descriptor's index. A Peer Notice cannot point into the mail Sidecar
directory, or the reverse.¶
Advisory members. provenance on a Peer Notice is
redundant with the tree and MAY be omitted; if present, it is advisory, and a
consumer MUST NOT derive or override the origin class from it.
sender_standing, if present, SHOULD be the existing
agent-tier value.¶
Write authority per tree is a behavioral obligation of the same kind as read-only inbound tolerance, single-writer and single-drainer operation, and ingest by copy (Section 4.1): invisible to a document validator, and checked operationally. If it were left to deployments, the separate tree would be decorative, because one writer that chose the tree by inspecting a member would be routing on a member with one level of indirection. The mechanism that enforces it (user identities, mount sets, a host boundary) is out of scope; the property is not, and this section prescribes a check for it.¶
Connector obligations. The tree a notice is read from is the
Runtime's sole assertion of its origin class: inbound/ is mail, and
peer/ is same-principal peer origin. A Connector MUST derive the
class from the tree, and MUST NOT derive or override it from any member of the
document. A document whose kind does not equal the name of the tree it
was read from is dropped under the rule of Section 7.1. A Connector
that cannot establish which tree a document came from MUST treat it as
mail.¶
The peer tree asserts authenticated origin: a same-principal Agent, reaching this Namespace through a path restricted to authorized peer senders. It asserts nothing about content. Peer content is authored by a peer and unscreened, and may paraphrase external mail (Section 12.7). How a Connector treats each class is out of scope; that it distinguishes the classes only by tree is not. Like the untrusted-content posture of Section 7, this obligation protects an Agent from its own inbox, and is stated as a Connector obligation rather than as a property the Runtime relies on. The invariant of Section 3.2 is unaffected.¶
Write-authority partition (Runtime). Write authority over
peer/ MUST be distinct from write authority over inbound/.
A component that admits messages from a transport not restricted to authorized
peer senders (the admitting component, of which a mail deliverer is
the usual example) MUST NOT hold write authority over peer/. The
restriction MUST be structural, and MUST NOT be configuration that the
component reads. The prescribed operational check is a write attempt against
peer/ from inside the admitting component, which MUST fail. The rule
is one-directional: a component holding peer write authority MAY write
inbound/, as the delivery failure notice of
Section 12.5 requires.¶
A Runtime MUST NOT write a notice into peer/ on the basis of
any claim the message makes about itself. It MAY write there only when the
message arrived through a path whose write authority is already restricted to
authorized peer senders, or when the message carries a signature that verifies
against a configured peer key (Section 12.6).¶
Verification placement (Runtime). Verification MUST be performed by a component holding peer write authority, which receives candidates through a location that is neither tree (Section 12.6.10). The admitting component MUST NOT perform verification. A verdict computed by one component and acted on by another would cross a trust boundary as data, which is what this rule keeps out of the decision; and the verifier processes hostile input, so it needs to be the component whose write authority the outcome concerns, not one that parses raw mail.¶
A peer address is a bare addr-spec, under the same pattern and
exclusions that Section 5.1 fixes for draft.to, with no
display name. Its local part is the Agent's name within its Runtime, and its
domain is the Runtime's authority. A deployment confined to one host, with no
mail, MAY use a non-routable domain. Traffic between hosts requires a routable
domain, because it travels as mail (Section 12.6).
"Routable" means deliverable by the mail system that carries the deployment's
peer traffic; the domain need not resolve in the public DNS, because peer keys
are configured rather than looked up (Section 12.6.7). A peer
domain MUST belong to exactly one Runtime among those configured as peers, so a
deployment that may peer across organizations should use domains it controls.
Where an Agent has a bound mailbox (the Binding Record's address,
Section 9), the deployment SHOULD use it, so that the Agent has one
identity on both lanes.¶
An addr-spec is used rather than an opaque identifier with a directory, or a URI scheme, because between hosts the transport address is an email address. Any other scheme would need a mapping onto one, and someone to run the mapping. With an addr-spec, the domain names the authority, the peer-key table is keyed by domain, and a signature has something to bind. The grammar, the schema pattern and the header-injection discipline already exist.¶
Sending needs no new wire shape. A sending Agent writes an
ordinary Submit Request (Section 5) whose draft.to names
the peer. The Runtime's recipient policy recognizes a declared edge and routes
the request to the peer tree rather than to mail. The Result vocabulary,
outbound Sidecars and reply key are the existing ones. Whether a recipient is
a peer is Runtime policy, and out of scope, like an allowlist.¶
message.from on a Peer Notice is authenticated. It is
the one from in this protocol that a consumer may treat as
authenticated origin: a bare addr-spec, pinned by the schema, and asserted by
the Runtime from the restricted write path or from a verified statement
(Section 12.6). A Runtime MUST NOT copy it from text the
sender supplied. This is the opposite of the rule for mail from
(Section 7.1). A Connector's sender check may key on it, because
the Runtime asserted it.¶
A configured peer key vouches only for its own Runtime's domains (Section 12.6.7). Without this, one Runtime's key could sign a message claiming an address in another Runtime's domain.¶
One address, one Namespace. A peer address resolves to exactly one Namespace, as every address does in this protocol: one Namespace, one Connector instance, one Agent. Sessions are below the Connector, and this document does not define them.¶
A peer message is a single unit addressed to one Agent. A Connector MUST NOT cause it to be acted on more than once. A Connector that cannot identify a single target MUST hold the notice rather than fan it out, and SHOULD report the condition through its own side channel (Section 12.8).¶
"One Agent, one live session; refuse when there are several" is one conformant implementation, not the only one. A finer target (a named session) is out of scope. A coarser one (a pool) is a Runtime alias that expands to several addresses, each resolving to one Namespace, and is not a change to addressing.¶
The peer message id. The sending Runtime mints the peer
message id when it accepts the submit. The id MUST be globally unique and
unguessable, with at least 128 bits of entropy, and MUST be spelled under the
notice_id character set (^[A-Za-z0-9_][A-Za-z0-9_.-]*$).
It has exactly one spelling inside every AMMP document:
message.id, message.in_reply_to and
message.references[] on a Peer Notice, message_id on a
peer-routed Result, and the reply key a sender submits all carry the bare id.
When the id is carried in a mail header, it is encoded as
<id@domain>, where domain is the domain of the sender's
address (Section 12.6.3). The encoding is applied on the way into the header and removed on
the way out, and MUST NOT appear in an AMMP document.¶
The reverse edge and every ledger depend on two properties of the id:
that it is unguessable, and that it has one spelling end to end. Bare
32-character hexadecimal satisfies both, and so does unpadded base64url
(subject, between hosts, to Section 12.6.1). A mail notice's
in_reply_to remains an [RFC5322] identifier; the Deliver
Notice schema is unchanged.¶
A reply is a Peer Notice in the reverse direction, on the same tree, correlated with the message it answers. It is not a third kind or a third tree: the tree grants the class, and a reply deserves the class of the message it answers. There is no correlation member; mail threading already provides one, and between hosts it is [RFC5322] threading.¶
| Member | On the peer lane |
|---|---|
message.id (Peer Notice) |
The peer message id. |
message_id (the sender's Result) |
MUST equal the peer message id, so that the sender learns it from its own Result. reason_code SHOULD be peer_routed, so that the sender can tell the lanes apart (an open vocabulary, Section 6.1). |
message.in_reply_to (Peer Notice) |
REQUIRED on a reply, and equal to the id being answered. A new task omits it. |
message.references[] (Peer Notice) |
Multi-step threads, as in mail. |
in_reply_to or draft.reply_to_message_id (Submit Request) |
How a responder names the message it answers. |
task_id remains an optional, Runtime-minted thread handle
(Section 8).¶
Reverse edges. The authorization graph is directed, yet a reply must be able to flow from B to A when only A to B is declared. A Runtime permits a reverse message when its reply key names a peer message in the opposite direction that the Runtime itself issued or placed (at submission, one it placed for the submitting Agent; at reception, one it issued to the replying Agent), within a configured window. This consults a member, but checks it against state the Runtime minted, which is the posture Section 8 takes for threading: correlation, not trust. Because ids are unguessable, the key is not a forgeable capability. Several replies to one id within the window, and a reply to a reply under the id the Runtime issued for it, are conformant. This document does not fix the window's length; it requires only that the window exists and is configured.¶
An unresolved reply key is refused, not stripped. A reply key
(in_reply_to, or else draft.reply_to_message_id) that the
Runtime cannot resolve from its own ledger MUST produce a rejected
Result with reason_code unresolved_reply. A Runtime MUST NOT
strip the key and route the message on draft.to as a new peer message.
A key that resolves but whose reverse edge is not permitted (no declared edge,
or an expired window) MUST likewise produce a rejected Result
(reason_code peer_reply_window_expired, or the Runtime's own
code), and MUST NOT be re-routed. A Peer Notice MUST carry
in_reply_to if and only if the placing Runtime resolved it from its own
ledger to a peer message id that it issued or placed.¶
The reason is the reason this profile exists. On the peer lane, a message the sender meant as a reply, which the Runtime quietly turned into a new task, arrives as a new instruction. That is worse than a rejection the sender can act on by resubmitting without the key. The rule is a requirement rather than a permission because a Connector may build policy on it (for example, admitting a replier it would not otherwise accept), and that is sound across a deployment only if every conformant Runtime upholds it. Such a policy MUST NOT widen anything else about the notice's treatment.¶
Between hosts, the sending Runtime has already rejected an unresolvable
key at submission. A verified statement whose in_reply_to the
receiving Runtime cannot resolve therefore indicates a stale or forged
capability from a configured peer. The receiver MUST NOT place it in
peer/ with the key, MUST NOT place it as a new peer task, and MUST log
the cause, and it SHOULD tell the sending Runtime with a signed Control Notice
(Section 12.6.8). The ledger binds the participants as well as
the id (Section 12.6.6). The statement is not demoted to mail
either: its origin was verified, only its correlation claim failed, and
routing it as mail would be a third silent transformation.¶
Two things called a result. A work result is the
responder's content for the requester, described above. A delivery
outcome is mechanical feedback from a Connector to its Runtime
(delivered, held, and so on). A delivery outcome is not a peer
message; it is kept in outbound/ext/<name>/
(Section 12.8).¶
Delivery failure notices. A Runtime that reports a terminal
delivery failure to the sending Agent SHOULD do so as a deliver Notice
in the sender's inbound/ tree, with message.from set to the
Runtime's own address (amap.router@<domain> or equivalent),
in_reply_to set to the peer message id, and a body authored by the
Runtime alone. Such a notice MUST NOT be placed in peer/, and MUST
NOT quote or forward any text from the receiving Connector's outcome file.¶
The Runtime is never an allowed peer sender, so this keeps it out of every
Connector's peer policy. The mail tree's untrusted-content posture is the right
one for "your message did not arrive". And the outcome file is text written by
the receiver, so forwarding it would let one Agent put prose in front of another
under the Runtime's name. A component holding peer write authority that writes
inbound/ is the one direction Section 12.3 permits.
The peer message id appears in a mail-tree notice, but only to the sender, who
already holds it from its own Result, so no capability is disclosed.¶
Peer traffic between hosts is mail. The sending Runtime signs it with its
own key; there are no per-Agent keys. Traffic on one host needs no signature,
because local write permission is a stronger guarantee, although a Runtime MAY
sign it too, for a uniform code path, without changing its treatment. A
receiving Runtime places a message from another host in peer/ only
when every rule in this section holds. Otherwise it hands the message to mail
admission, drops it, or refuses it, as Section 12.6.4
specifies.¶
The sending Runtime builds a statement, a small JSON object, signs its exact bytes with Ed25519 (Section 5.1 of [RFC8032]), and carries the bytes and the signature in one header field (Section 12.6.2). The receiver verifies the signature over the bytes it received before parsing them. Nothing is re-serialized: no canonicalization is defined, and whatever serialization the sender chose is the one that is signed.¶
The peer-statement schema (Appendix F) pins the statement:
version v 1, closed at every level, with the members v,
key_id, kind ("peer"), id,
in_reply_to (optional), from, to, ts,
subject, body_sha256 (the digest of the body the receiver
will publish), attachments (each with sha256,
size_bytes, filename and media_type, in leaf order),
sender_exposure (optional, Section 12.7) and
sender_since (optional, Section 12.6.9).¶
Unlike the Runtime-authored notices (Section 13.1), the
statement is closed, because it is signed bytes that another Runtime acts on.
A member the receiver does not know is a member it cannot bind to what it
publishes, and v is the path for evolution. The schema states the
shapes. The following rules, which JSON Schema cannot express, are also part of
the statement:¶
Formats. ts, sender_since and the
timestamps in sender_exposure are [RFC3339] date-times in
UTC, in whole seconds, with the Z designator, and each MUST denote a
real instant. Digests are SHA-256 [FIPS180-4], in lowercase
hexadecimal. Every number is a lexical integer, with no fraction and no
exponent, so 1.0 is not 1. No string contains an unpaired
surrogate (Section 8.2 of [RFC8259]).¶
Ids. A sending Runtime MUST mint the ids it sends between
hosts under ^[A-Za-z0-9_][A-Za-z0-9_-]*$, which is the
notice_id character set (Section 12.5) without
".", with at least 128 bits of entropy, so that
<id@domain> is a valid msg-id
(Section 3.6.4 of [RFC5322]). Hexadecimal satisfies this; a
base64url id whose first character is "-" MUST be minted again. The
statement schema enforces the pattern for id and
in_reply_to. An id used only on one host MAY also contain
".", as the notice_id character set allows; an id sent
between hosts MUST NOT.¶
to is one address: a peer message is addressed to one
Agent (Section 12.4).¶
sender_exposure obeys
Section 12.7: window_start is not later than
window_end; last_external_delivery_ts is present if and only
if external_mail_delivered is true, and then lies within the
window; and asserted_by is the signing Runtime's configured Runtime
address (Section 12.6.7).¶
sender_since, when present, is not later than
ts.¶
The header field.¶
AMAP-Peer-Statement: k=<key_id>; s=<statement>; b=<signature>¶
s is the statement's bytes, and b is the 64-byte
signature over exactly those bytes. Each is base64url without padding
(Section 5 of [RFC4648]).¶
k is the statement's key_id, in the clear: the receiver
verifies before it parses, so it must choose the key before it can read the
statement. The signed key_id MUST equal k.¶
The field name is matched case-insensitively. The sending Runtime MUST emit exactly one such field.¶
The sender MUST fold the field so that no line exceeds 998 octets (Section 2.1.1 of [RFC5322]), and SHOULD fold at 78. It may insert folding whitespace anywhere, because the receiver removes all of it.¶
A statement MUST NOT exceed 8192 bytes. A sending Runtime that would
otherwise emit a larger one SHOULD refuse the submit instead, with a
rejected Result whose reason_code is
peer_statement_too_large.¶
The sending Runtime MUST also set Message-ID to
<id@domain>, where domain is the domain of
from, and SHOULD set In-Reply-To and References to
the encoded forms (Section 12.5), so that mail threads
coherently and a demoted message still threads as mail.¶
The message shape. A sending Runtime SHOULD send either a
single text/plain part, or multipart/mixed holding one
text/plain part followed by the attachments. The text part SHOULD be
charset=utf-8 with base64 transfer encoding, so that transport cannot
rewrite it. The sender hashes the UTF-8 encoding of the body after replacing
every CRLF pair with LF. Nothing else is normalized: a bare CR, a bare LF,
trailing whitespace and a leading dot are all content.¶
The selection rule. The admitting component reduces a message to a candidate, and the rule is the same at every receiver:¶
Leaves. Walk the MIME tree [RFC2046] depth-first,
descending only into multipart/*. Every other part is a leaf, including
message/rfc822, whose octets are its part body as transmitted.¶
Body. The first leaf of type text/plain whose
Content-Disposition [RFC2183] is not
attachment is the body.¶
Attachments. Every other leaf, in walk order, is an
attachment: a text/html alternative, an inline image, or a second
text/plain part.¶
No body leaf means the empty body.¶
A different structure is not forbidden; it is simply likely to fail the attachment count or the body digest, and so to be demoted.¶
The candidate. The intake location (Section 12.6.10) MUST hold the following, in a layout this document does not prescribe:¶
Field bodies. The body of each
AMAP-Peer-Statement, AMAP-Peer-Control and
Message-ID field, in message order. The admitting component MAY
unfold a field body or trim its surrounding whitespace, and MUST NOT otherwise
alter it. A field body over 12288 octets MUST be carried as an
oversize marker, and MUST NOT be truncated. The 12288-octet limit
applies to the field body as transmitted, before unfolding or trimming. A field
body the admitting component cannot carry as ASCII text (a non-ASCII octet, or
an undecodable value) MUST be carried as an invalid marker in the
field's position. A marker keeps the field's identity and multiplicity: the
admitting component MUST NOT omit a field, because omitting a second, malformed
statement field would leave one well-formed field, and the verifier would select
it.¶
The body leaf, if there is one: its octets after transfer
decoding, and the value of its charset parameter, if any.¶
Each attachment leaf: its octets after transfer decoding, in order.¶
The verifier, not the admitting component, decodes the body octets
strictly. The charset label, compared case-insensitively, MUST be
utf-8 or us-ascii; an absent label means us-ascii.
Any other label, or octets that are invalid in the labelled charset, fail check
9 (Section 12.6.3). The verifier then replaces CRLF pairs
with LF and hashes the result, and that same text is what it publishes as
body_text.¶
What is published. On placement, the receiver takes all of the
following from the verified statement, and MUST NOT take them from headers:
message.id, in_reply_to and from; subject;
sender_exposure; and the attachment Descriptors' filename,
media_type, size_bytes and sha256. to selects
the Namespace. The receiver MUST NOT publish references from headers;
it MAY derive them from its own ledger (Section 12.6.6). The
byte sniffing, caps and disposition rules of Section 7.2
still apply to the attachment bytes.¶
Comparisons. Addresses (from, to, the edge
graph, the ledger and Runtime addresses) compare the local part exactly and the
domain case-insensitively in ASCII. Ids compare exactly, because they are
opaque. After unfolding and trimming, Message-ID MUST be exactly
"<", the id, "@", the domain and ">", with
the id equal to the statement's id and the domain equal to the domain
of from.¶
Logging. A receiver MUST NOT log a raw header field value
unescaped. Fields are supplied by the attacker, and k is validated
against the key_id pattern before it is used for anything.¶
The receiver applies these checks in this order. The first one that fails
decides the outcome (Section 12.6.4). Only a candidate that
passes every check is eligible for peer/.¶
The field. There is exactly one
AMAP-Peer-Statement field; with none, the message is not a peer
candidate, and is ordinary mail. A message that also carries an
AMAP-Peer-Control field is dropped
(Section 12.6.8). The field body is ASCII and is not
oversize. With every SP, HTAB, CR and LF removed, it consists of exactly the
tags k, s and b, each once, in any order, separated
by ";". k matches the key_id pattern. s and
b are unpadded base64url; b decodes to 64 bytes, and
s to at most 8192.¶
Known statement. If the SHA-256 digest of the statement bytes equals that of a statement the receiver has recorded and still retains (Section 12.6.5), the candidate is a duplicate. This check needs no key, and holds whatever else has changed since: a timestamp now outside the window, a key since expired or removed, or a body modified in transit.¶
Key. k names exactly one configured key, valid at
the receiver's clock (not-before ≤ now ≤ not-after, inclusive).¶
Signature. The signature is valid for that key over the received bytes, under the verification policy of Section 12.6.7.¶
Statement. (a) The bytes are UTF-8 without a byte-order mark,
and parse as JSON with no repeated member name and no non-JSON constant. They
validate against the schema, and key_id equals k. (b) Every
rule of Section 12.6.1 that the schema cannot express holds.¶
Key domain. The domain of from is one of the domains
configured for the Runtime that owns the key. A key vouches only for its own
Runtime's domains (Section 12.4).¶
Freshness. ts lies within the window of
Section 12.6.5. While peer admission is closed (no trustworthy
bound, Section 12.6.5), the window is undefined: the candidate
is held in intake, undisposed, until admission reopens.¶
Exposure asserter. If sender_exposure is present,
asserted_by is the configured Runtime address of the Runtime that owns
the key.¶
Body. The body decodes under the charset rule of
Section 12.6.2, and its digest equals
body_sha256. The verifier MUST hash bytes it holds itself, and MUST
NOT use a digest that the admitting component computed.¶
Attachments. The count matches; then, for each attachment in
order, sha256 and size_bytes match the octets.¶
Message-ID. There is exactly one such field, matching
Section 12.6.2.¶
Reservation. No record exists for the pair (the configured
Runtime that owns the key, id) (Section 12.6.5).
The candidate is reserved, atomically.¶
A reserved candidate is then decided, and the following are not verification failures:¶
if to is not hosted here, the candidate is misrouted;¶
if in_reply_to is present, it must resolve and be permitted
(Section 12.6.6);¶
otherwise, the receiver's own graph must have the edge from
from to to (Section 12.6.6);¶
any deployment admission policy is applied (Section 12.6.9);¶
otherwise, the decision is placement.¶
The decision MUST be persisted on the record before it has any effect (Section 12.6.5). Only a persisted placement decision is ever published; a persisted refusal or drop MUST NOT be published, whatever happens afterwards.¶
| Outcome | When | Control Notice |
|---|---|---|
peer
|
Every check passes, and the message is routed and permitted. | none |
| held | Check 7 while peer admission is closed (Section 12.6.5); not yet disposed of. | none |
| mail, no statement | No AMAP-Peer-Statement field. |
none |
| mail, demoted | A failure of check 1 (other than the both-fields drop below), 3, 4, 5, 6, 7, 8, 9, 10 or 11. | SHOULD, for 5(b) and 6 to 11 |
| drop: both fields | An AMAP-Peer-Statement and an AMAP-Peer-Control field in one message (Section 12.6.8). |
none |
| drop: duplicate | Check 2 (a known statement) or check 12 (a known id). |
SHOULD, for check 12 only |
| drop: misrouted |
to is not hosted here. |
SHOULD |
| refuse: unresolved reply | Section 12.6.6. | SHOULD |
| refuse: no edge | Section 12.6.6. | SHOULD |
| refuse: policy | A deployment admission policy (Section 12.6.9). | SHOULD, with policy_code
|
Mail, demoted. Demotion is a classification, not a delivery.
The candidate goes to ordinary mail admission and the deployment's lane policy;
where that policy gives the pair no mail lane, it is not delivered. The demoted
notice MUST NOT carry sender_exposure, the statement or the
signature, and carries the mail-admission verdict the admitting component
attached. Mail admission MUST NOT treat an invalid statement as evidence of who
sent the message.¶
Drop and refuse. A dropped or refused candidate is neither
placed nor handed to mail admission. Every outcome other than peer and
"mail, no statement" MUST be logged with its cause. A refusal is not demoted,
because the origin was verified and only an authorization or correlation claim
failed; routing it as mail would be a silent transformation
(Section 12.5).¶
Who is told. A Control Notice
(Section 12.6.8) MUST be sent only when every one of these
holds; a configured k alone is never enough:¶
checks 1 to 5(a) passed, so the statement is authenticated by a currently valid configured key and is well-formed enough to name. Before check 4 the sender is unauthenticated, and notifying the claimed sender would be backscatter; between checks 4 and 5(a), the statement cannot be named;¶
the candidate was not a duplicate caught at check 2 (an exact retained statement: the first copy was already answered, and a replay on the path must not draw a second notice);¶
the candidate is a statement, not a Control Notice: nothing is ever sent about a Control Notice (Section 12.6.8);¶
the destination is the Runtime address configured for the key's owner, and never an address taken from the message.¶
A "dropped, duplicate" notice (check 12) tells the sender that a record
with that id already exists. That record's disposition may have been
placement or refusal: the notice proves a prior reservation, not that the task
failed. Whether and how the sending Agent learns of an outcome is governed by
Section 12.6.8.¶
Windows. The receiver accepts ts when
lower ≤ ts ≤ now + F, both bounds inclusive, where
lower = max(H − P, floor):¶
F, the allowance for clock skew, defaults to 300 seconds and MUST NOT exceed 900.¶
P, the past window, defaults to 3600 seconds, and MUST be at least 300 and at most 86400. A message that arrives later than P is demoted, which is visible to the sender. A longer P costs replay storage and a longer key retirement (Section 12.6.7).¶
H is the latest receiver time ever observed, kept durably. The lower bound uses H rather than the current clock, so that a clock that steps backwards cannot re-admit a statement whose record has been pruned.¶
floor is set only by recovery and by window changes, both below.¶
Records. Replay identity is scoped to the pair (configured
Runtime, id), where the Runtime is the configured peer that owns the
verifying key, so that rotating a key never resets it. Each record holds the
scope, the id and ts; the SHA-256 digest of the statement
bytes; the notice_id minted at reservation; a state, which is
reserved, decided or done; and, once decided, the
disposition: placement, or a refusal or drop with its cause (and
policy_code), and whether a Control Notice is still pending.¶
Recording. The receiver records a candidate at check 12, after checks 1 to 11 have passed. A candidate that fails any of them records nothing, so a mangled copy cannot suppress the intact one that follows. Misrouted and refused candidates are recorded too, so that their replays are dropped quietly instead of being evaluated again each time. Control Notices (Section 12.6.8) use the same store.¶
Reservation, decision and effect. These are three durable steps, in order, each atomic:¶
Reserve. Check 12 creates the reserved record and
mints a fresh notice_id. The candidate stays in the intake location
until the record is done. A reservation is undecided, and authorizes
nothing.¶
Decide. The receiver routes, correlates, permits and applies
policy (Section 12.6.3), and persists the disposition on the
record, which becomes decided. A refusal or drop also marks its control
notice as pending.¶
Effect. For a placement, the notice is written under the
minted notice_id; for a refusal or drop, the Control Notice is durably
queued. Only then is the record done. Placement is idempotent: a notice
that is already present is not written again.¶
A copy that arrives while another copy is reserved or
decided matches it at check 2, and is dropped. On restart, the receiver
MUST finish every record that is not done, exactly once, by resuming
its recorded state, and MUST NOT infer a disposition from the absence
of a notice:¶
a reserved record is decided then, under the configuration in
force at recovery (the receiver has no older one it can trust), and then takes
effect;¶
a decided placement is placed if the notice is absent, and is
marked done without placing it again if the notice is present;¶
a decided refusal or drop is not published: its pending control
notice is queued, once, and the record is marked done.¶
An implementation MUST survive a crash at each of these points: after reservation, after the decision, after a notice is placed, after a control notice is queued, and after a terminal refusal is recorded, including when the graph or policy changed while it was down.¶
Retention. The receiver MUST retain each done record
while H ≤ ts + P, and MAY retain it longer. It MUST NOT prune a
record that is not done. A known statement replayed after its retention
has lapsed fails freshness, so it is demoted and never placed. Duplicate
suppression is therefore effective only within retention: this profile does not
promise exactly-once delivery, and an implementation MUST NOT claim it.¶
Resending. A sending Runtime whose hand-off was ambiguous MAY
resend the same id, signed again with a new ts. If the first
copy arrived and is retained, the resend is dropped at check 12, and the control
notice tells the sender so; if the first copy did not arrive, the resend is
placed. A sending Runtime that resends MUST keep the same id, including
across a resend that a person approved.¶
This permission is idempotent only within retention. Once the first
copy's record has been pruned, a resend of the same id under a fresh
ts finds no record, and is placed a second time. A deployment that
cannot accept that MUST forbid signing again in its own deployment profile, and
resend only the original bytes, which check 2 drops or freshness demotes. The
permission is kept for portability, not as a recommendation.¶
Recovery. The store and H MUST survive a restart. If the receiver loses the store, or cannot show that a restored store is complete, it MUST restore this barrier: no statement that could already have been accepted under the lost coverage may be admitted again, including at the boundary, under a clock that stepped back, and under the largest F that was ever in effect.¶
With H intact, the receiver sets floor to one second
after max(C, H) + Fmax, persisted before admission resumes. C is the
moment from which it again holds complete evidence, H is the high-water clock at
the loss, and Fmax is the largest F in effect at any point that could have
admitted a statement still within the lost coverage. Fmax is taken from the
retained history of F if that history is provable, and is 900 otherwise. Fmax
MUST NOT be chosen from the wall-clock interval ending at C: after a clock rollback,
that interval can miss a larger F that was in effect near H. Retaining the
largest F ever configured is safe. Any statement accepted before the loss had
ts ≤ H + F ≤ max(C, H) + Fmax, so none reaches the floor. The floor
is strictly above that bound: an inclusive floor would re-admit a statement
accepted at exactly now + F, and C alone would re-admit one accepted
before the clock stepped back.¶
With H lost as well, no trustworthy bound exists, and the
receiver MUST close peer admission: candidates reaching check 7 are held in
intake, and nothing is placed or demoted on a window the receiver cannot define.
Admission reopens only when the operator records a bound B that is at or after
the largest receiver time before the loss. The floor is then one second after
B + Fmax, with Fmax determined as above: losing H does not by itself
lose the history of F. The held candidates are then evaluated in arrival order.
The current wall-clock time is not a bound.¶
Window changes. Narrowing P or F takes effect at once. Widening P MUST NOT re-admit anything: the receiver sets the floor to the lower bound in effect at the moment of the change, and the window then widens only as records accumulate.¶
The ledger. A Runtime keeps a record for every peer message it
issued to another host or placed. Each record holds the id; the local Agent and
the remote Agent; the direction (issued or placed); and an
expiry, the configured reply window (Section 12.5). The ledger
is scoped to peer messages: a mail notice id never resolves.¶
Resolution. A verified statement carrying in_reply_to
resolves only if a record exists whose id equals in_reply_to, whose
local Agent equals to, whose remote Agent equals from, and
which has not expired. Possession of an id is not authority: an id presented by
another Agent on the same host, or by any Agent on another host, does not
resolve.¶
Permission. A resolved reply to a record this Runtime
issued is a reverse message, and is permitted without an edge
(Section 12.5). A resolved message against a record this
Runtime placed is a follow-up in the same direction, and needs the
edge from from to to. A statement without
in_reply_to needs the edge.¶
The receiver's own graph. The edge MUST be checked against the receiver's own graph. The sending Runtime checked its edge at submission (Section 12.4), but which peers may task an Agent is the receiving Runtime's decision. If correlation fails, the outcome is "refuse: unresolved reply"; if permission fails, it is "refuse: no edge". Neither is demoted.¶
At submission. The sending Runtime MUST apply the same binding: a reply key resolves only if the submitting Agent is the record's local Agent. It SHOULD also refuse at submission a message it already knows the receiver will refuse, such as one for a pair it knows has no edge or is held by policy, so that the Agent learns of it at once. The receiver remains the authority.¶
Configuration. The operator configures, at every receiver, each
peer Runtime's domain set, its Runtime address
(amap.router@<domain> or equivalent), and its key set. Trust
anchors are configured, not discovered: there is no DNS publication, directory
or certificate chain. A receiver MUST refuse to load a configuration in
which:¶
a key_id occurs twice, across all peers (the receiver
selects a key by k alone);¶
a not-before time is later than its not-after time;¶
a public key is not a canonical encoding of a curve point; or¶
a public key is of small order.¶
A sending Runtime SHOULD make its key ids globally unique, for example by deriving them from a fingerprint of the public key.¶
These are configuration-time checks, a gate separate from verification. A bare Ed25519 implementation will construct and verify under a small-order or non-canonical key; the key loader, not the verifier, is what refuses one. An implementation MUST demonstrate the refusal through its loader, and MUST NOT count passing the vectors marked for rejection at verification as covering it.¶
Rotation. A receiver MUST support at least two concurrent keys per peer. A rotation proceeds as follows:¶
Add the new key at every receiver.¶
Switch the sender to it.¶
If the sender stops using the old key at time T, receivers keep the
old key valid until at least T + F + P, because a statement signed at T can carry
a ts up to T + F, and arrive up to P after that.¶
Validity is inclusive at both ends. Revocation is removing a key or ending its validity. Replay records are scoped to the Runtime rather than the key, so neither retiring nor revoking a key erases replay evidence (Section 12.6.5).¶
The signing key is a Runtime credential
(Section 3.1). It MUST NOT be readable from any Agent Namespace or
by the admitting component. It MUST NOT be used to sign anything except this
profile's statements and Control Notices, which their kind members
keep apart.¶
Verification policy. Verification is Ed25519 as specified in [RFC8032], with the cofactorless equation [S]B = R + [k]A. A verifier MUST reject a signature whose S is not less than L; a non-canonical encoding of R or A; and a key that failed the configuration checks above. A point encoding MUST be decoded canonically before any other check is applied to it: the "negative zero" encoding of x = 0 (y = p − 1 with the sign bit set) is non-canonical, and is rejected as such before a small-order check is reached.¶
This document requires nothing about how Ed25519 is computed. A vetted library, a system binary or any other implementation conforms if it produces these results. The conformance suite holds the test vectors of Section 7.1 of [RFC8032] (TEST 1 to 3), and negative vectors marked for rejection either at verification or at configuration (Appendix J). An implementation MUST pass both kinds through the implementation it ships.¶
A receiving Runtime tells a configured sending Runtime what it did with a
statement it did not place, using a signed Control Notice. The
peer-control schema (Appendix F) pins it: version v 1,
closed, with the members v, key_id, kind
("control"), id, from, to, ts,
about, outcome, cause, and policy_code
(with cause policy).¶
Form. A Control Notice is carried in an
AMAP-Peer-Control header field with the k, s and
b grammar of Section 12.6.2, signed with the same
per-Runtime key. Its kind makes it impossible to read as a peer
statement, and the reverse. It carries no free text: the cause vocabulary is
closed, and the receiving Runtime composes any words itself. It travels as mail
between Runtime addresses.¶
When. A receiver SHOULD send one for every outcome
Section 12.6.4 marks, and MUST send one only when the
eligibility conditions of Section 12.6.4 hold in full: never
for a failure before check 5(b), never for a duplicate caught at check 2, never
about a Control Notice, and never merely because a key was found. There is one
notice per record: the pending notice is part of the record's disposition
(Section 12.6.5), MUST be queued durably before the record is
done, and MUST be sent at most once, including across a crash. A
receiver MAY rate-limit Control Notices; any suppression MUST be logged.¶
policy_code is defined by the deployment. A receiving
Runtime renders a description it defines locally. An unknown token gets a
generic description, and MUST NOT be interpreted.¶
Addressing. from is the receiver's own Runtime address.
to MUST be the Runtime address configured for the peer that owns the
verifying key, and MUST NOT be an address taken from the message. A control
notice MUST NOT be sent to a Runtime that is not a configured peer.¶
Receiving one. A Runtime applies checks 1 to 5 of
Section 12.6.3, with AMAP-Peer-Control and the
control schema in place of the statement's. It then requires all of these:
from is the Runtime address of the peer that owns the key; to
is its own Runtime address; ts is fresh
(Section 12.6.5); the notice's pair (Runtime, id) is
new; and about names a ledger record that this Runtime
issued to an Agent in the signer's domains.¶
What it does. It SHOULD give the issuing Agent a failure
notice authored by the Runtime in inbound/ (the delivery failure notice
of Section 12.5), with no text from the Control Notice except
the vocabulary values. Where it does, the failure notice is owed from the
moment the record is reserved, and the record is done only once the
failure notice is durable; a crash in between is finished by recovery, once.¶
One per message. An issued record receives at most one failure
notice. A later Control Notice about the same about, under a different
id, is dropped. A "dropped, duplicate" report means that a record for the
message exists at the receiver, whose disposition may have been placement; it is
not proof that the task failed.¶
Failures. A Control Notice that fails any rule is dropped and logged: it MUST NOT be placed, handed to mail admission, or answered. A message carrying both fields is dropped.¶
A deployment MAY refuse authenticated peer messages for reasons of its
own, such as a first-sight quarantine for a new sender, or a requirement that
every delegation between hosts carry sender_exposure. The refusal is
"refuse: policy", with a policy_code defined by the deployment. The
policies themselves are the deployment's business, not this profile's.¶
Measuring a sender's age at the receiver, from first sight, would make
every Agent's first delegation to each host fail. The optional signed member
sender_since exists to avoid that:¶
It is the time at which the sending Runtime first enrolled the sending Agent's identity, as that Runtime defines identity, taken from its durable registry.¶
It is a Runtime assertion, covered by the signature, and MUST NOT be taken from text the sender supplied.¶
It has the format of ts, and is not later than ts.
Skew is absorbed by the age itself: a receiver measures age as its own clock
minus sender_since, so a few minutes of skew change nothing under a
policy measured in hours.¶
Continuity. The sending Runtime MUST NOT reset it for a continuation of the same identity: a restart, a rebinding, a re-creation or a key rotation keeps the value. A new enrollment of the identity starts a new value. The sending Runtime decides which of the two a given event is, and MUST decide it the same way every time for that identity.¶
Consistency. A receiver that enforces a sender-age policy MUST
keep the last value it accepted for each pair (Runtime, from). A later
statement that asserts an earlier value claims an older enrollment
than the Runtime previously asserted; it is inconsistent, and the receiver
refuses it with its own policy_code. A later value is a new
enrollment, and the age starts again.¶
A receiver MAY enforce a minimum sender age from it. Its absence means
"not asserted", and a receiver MUST NOT read it as "new" or as "old enough". A deployment that requires it
refuses its absence with its own policy_code.¶
The intake location is REQUIRED, as a structural property with no
prescribed layout. A message admitted from a shared transport that presents as a
peer or control candidate MUST be held, until it is disposed of, in a location
that is neither inbound/ nor peer/, that is writable by the
admitting component and readable by the verifying component, and that is
unreachable from any Agent Namespace. Candidates MUST be held as the candidate of
Section 12.6.2: the verifier MUST NOT parse mail. The verifier
MUST apply the read-side discipline of Section 4.1 to candidates,
since the admitting component is less trusted than the verifier. The verifier
disposes of each candidate exactly once: it places it in peer/,
minting the notice_id at reservation
(Section 12.6.5); or it hands it to mail admission, which, if
it delivers, places it in inbound/ under a notice_id minted
there; or it drops or refuses it, logging the cause. A candidate held while peer
admission is closed (Section 12.6.5) is not yet disposed of,
and stays in the intake location until admission reopens.¶
How a Runtime divides its intake (one location or several, for example by candidate kind), and where it keeps the traffic it authors itself, are implementation choices; this section constrains only the properties above.¶
The split between Runtime assertions and sender claims
(Section 7.1) applies: disposition, verdict and
sender_standing are Runtime assertions, while subject and
body_text (and, on the mail tree, from) are sender claims. An exposure record
belongs on the assertion side. It records delivery, not
reading: a Runtime can know that it delivered external mail to the
sender's Namespace, but cannot know what the sender did with it.¶
A Peer Notice asserting exposure, with the stamp in message.sender_exposure:¶
{
"contract_version": "2",
"notice_id": "7b2a1c0d9e8f47a6b5c4d3e2f1a0b9c8",
"ts": "2026-09-03T12:05:00Z",
"kind": "peer",
"message": {
"id": "7b2a1c0d9e8f47a6b5c4d3e2f1a0b9c8",
"from": "triage@fleet.example",
"subject": "Customer asks for an export",
"preview": "A customer mail this morning asks whether",
"mailbox": "peer",
"sender_exposure": {
"asserted_by": "amap.router@fleet.example",
"external_mail_delivered": true,
"window_start": "2026-09-02T12:05:00Z",
"window_end": "2026-09-03T12:05:00Z",
"last_external_delivery_ts": "2026-09-03T08:41:10Z",
"derived_from": ["1721457600000000001-4242"]
}
}
}
Placement. It is a member of message, beside
sender_standing, provenance and verdict, so that the
Runtime's assessments of the message are kept together. The schema requires
asserted_by (a bare addr-spec naming the asserting Runtime; between
hosts, the signer), external_mail_delivered, window_start and
window_end, and requires last_external_delivery_ts when the
boolean is true. Its absence when the boolean is false is
stated here, not in the schema.¶
Shape. A boolean over an explicit window, plus the most
recent timestamp. There is no count, because a count would assert a precision
that a measure of delivery lacks. derived_from is optional, and present
only where the Runtime observed the derivation itself (the sender's reply key
named a notice in its mail tree).¶
Absence means "not assessed". A consumer MUST NOT read the
member's absence as "no exposure".
false, asserted, is a stronger statement than the member omitted: it
says that the Runtime assessed the window and delivered no external mail. A
Runtime that is the only writer of a sender's mail tree, and has written there
only same-principal Agent mail and its own delivery failure notices, SHOULD
assert false. Notices the Runtime authors in the mail tree (the
delivery failure notices of Section 12.5) are not external
mail, and MUST NOT make the boolean true. Moving window_start forward to the
moment the Runtime's assessment began (for example, an instance's approval time
after a reset) keeps the window on the wire equal to the span assessed.¶
Not settable by the sender. On one host, the Submit Request
envelope is closed (Section 13.1), so a request carrying the
member is rejected, which is stronger than stripping it. Between hosts,
it is taken only from the verified statement, and is removed on demotion
(Section 12.6.4). A Runtime MUST NOT accept it from any
sender.¶
Use. It MUST NOT drive routing or treatment (Section 12.3). It is advisory, for audit, and covered by the signature.¶
Lineage declared by the sender is not included. Such a
member would ride on the Submit Request, whose envelope is closed, so adding it
would be a major change; it would work only when the sender cooperates, which is
the case where it is least needed; and a forgeable member invites readers to
trust it. Most of its value is recovered without one: when a sender's reply key
names a notice in its mail tree, the Runtime knows which external
message the peer message derives from, and records that as its own observation
(derived_from, and the audit log).¶
Delivery outcomes are not a profile artifact. A Connector that
reports what it did with a notice (delivered, held,
refused, and so on) writes the report under
outbound/ext/<name>/, in a shape agreed out of band with its
Runtime, under the stable Connector id that Section 4.1 requires.
Section 4.1 carries the one normative rule: an outcome is a claim by
the receiving side, never proof of delivery or of non-delivery, and the
Runtime's own delivery record is authoritative over it
(Section 14.15). If a second Connector needs outcomes, that is the
point at which to define a shape for them in this profile.¶
Retention. The record is the audit log, not the spool. The
Runtime logs every candidate it disposes of (id, from, to, tree,
exposure, signature verdict, and any demotion or refusal cause) to the per-Agent
audit/log.jsonl it owns, and to its own store of record. Spool
retention for peer/ follows the mail row of the deletion table
(Section 4.2) unchanged.¶
Deletion rights. The deletion table
(Section 4.2) applies to peer/ artifacts exactly as to
inbound ones: written by the Runtime, and deleted by the Runtime only.¶
Presentation. A Connector presenting a peer body MUST meet the
presentation obligations of Section 7.1, with one prohibition and
two additions. It MUST NOT convey that the sender is unauthenticated: on this
tree, message.from is asserted by the Runtime from the restricted write
path (Section 12.4), and saying otherwise contradicts the
mechanism the tree exists to provide. It SHOULD convey that an authenticated
request is still bounded by what the receiving Agent is authorized to do: a
verified sender can ask for something the receiver may not perform, and a
framing that reads as a grant invites the reader to treat verified identity as
having settled that question. It SHOULD convey that authenticated origin is not
a warranty about content: peer content is authored by a peer and unscreened,
and may paraphrase external mail (Section 12.7).
Authenticated who; unverified what.¶
None of this is an enforcement mechanism. Enforcement on this tree is the write-authority partition and the origin-follows-the-tree rule (Section 12.3); presentation is what a Connector states to a reader who can ignore it. The reason to get it right is that the alternative asserts something false.¶
The fail-closed rule. contract_version is a major-version string. A
consumer that reads a higher major than it speaks MUST fail closed
— refuse to process the artifact — and MUST NOT silently mis-parse it.¶
Version-check ordering. A consumer MUST implement the version check by
reading contract_version before any schema validation, comparing
majors as non-negative decimal integers. An absent major, a lower major, or
a higher major is a version refusal: fail closed, as a distinct, logged
outcome, never folded into the malformed-document drop path of
Section 7.1.¶
Checking the version first is what makes fail-closed implementable at all. A validator that runs schema checks first cannot distinguish "wrong version" from "malformed," and a strict schema would reject a future-major document for the wrong reason entirely.¶
This ordering rule is about inbound artifacts a Connector consumes, which
have no mechanism in this protocol to carry a distinct outcome back to the
Runtime. It does not reach the outbound direction: a Runtime
version-refusing a Submit Request MAY carry that refusal on the ordinary
request-rejection path — a top-level rejected Result with
reason_code: "unsupported_contract_version" (Section 6.1) — and, per the
conformance classes below, MUST do so for the version-absent request
fixture. That is still a distinct, legible outcome rather than a fold into
the malformed-request case: reason_code is exactly this protocol's
mechanism for saying why a rejected fired, and a consumer MUST already
tolerate a reason_code it does not otherwise interpret. A Runtime has no
equivalent document to write for a version-refused Notice it never emits
— hence the asymmetry: logged-only inbound, rejected outbound.¶
A version signal, not member rejection, is the boundary. Rejecting a document because it carries an unrecognized member is not a version mechanism. Under closed envelopes, a consumer that meets a new member rejects the whole document: inbound, that is silent loss of the message; outbound, it is an accidental schema error rather than a designed refusal. And while the version string is unchanged, nothing can react to the new member. A change that a consumer of the current wire major would reject or misread as a whole document therefore requires a new wire major, so that the consumer refuses it by version, legibly, and both sides upgrade deliberately. Schema strictness and protocol version are separate axes, and this document does not use the first as the second.¶
Unknown-member tolerance is direction-aware. The rule is stated per direction because the two directions have opposite threat models.¶
Runtime-authored artifacts — Deliver Notice, Inbound Message, Result,
Peer Notice — are open. A consumer MUST ignore members it does not recognize, at
every level of the document, and MUST NOT reject, drop, or fail the
artifact on their account. Strictness in this direction defends nothing:
the producer is trusted by construction, and in the one posture where an
attacker can author a Notice — a writable inbound tree — that attacker can
author a fully schema-valid one, so strictness is not the defense there. Its
failure mode is silent loss of mail. Emitting an unknown member is still a
producer non-conformance: a Runtime MUST NOT emit members this
document does not define. In the schemas, additionalProperties:
false is absent from the deliver-notice, inbound-message, result and
peer-notice schemas at every level — top level, message, Descriptor
items, and attestation — while the clean-gating conditionals are
orthogonal and remain.¶
The Submit Request envelope is closed, additionalProperties:
false at every level. Closure does real security work there: it is
what bars an outbound content_ref and any other smuggled member; the
validator is the trusted Runtime; and a refusal is a legible,
per-request rejected Result, never a silent loss. A Runtime MUST reject
a request carrying unrecognized members.¶
Consequently: adding an optional member to a Runtime-authored artifact is additive and needs no new wire major; adding any member to the Submit Request requires one.¶
The Binding Record is closed, for the separate reason given in
Section 9.1. The signed Peer Statement and Control Notice are closed
because they are signed bytes another Runtime acts on
(Section 12.6.1); they carry their own version member,
v.¶
Tolerance extends to values, not only members. An optional, explicitly
advisory enum on a Runtime-authored artifact — message.sender_standing and
message.provenance (Section 7.1) — is scoped the same way. A consumer
MUST tolerate a value it does not recognize, treating it as opaque or as the
member being absent, and MUST NOT reject, drop, or fail the artifact on
that account; a Runtime MAY add a value to either vocabulary without a new wire
major. Without this, a Runtime adding a new sender_standing or
provenance value would silently lose every Notice carrying it, against
every Connector still validating the old enum — the member case again, at the
value level. In the schemas, the enum constraint is absent from these two
members.¶
This is not a universal loosening of every enum. kind, outcome —
both the top-level Result outcome and the per-attachment outcome — and the
inbound Descriptor's disposition remain closed, enum-locked
vocabularies. kind and outcome gate which processing path a document
takes at all, and disposition gates byte publication (Section 7.2), so a
new value in any of them is a behavioral fork a consumer cannot safely treat
as opaque. Introducing one requires a new wire major.¶
When the wire major changes, and when it does not. The wire
major exists to force a legible, fail-closed refusal where a whole
document that a counterpart already accepts would otherwise be silently
misread or silently dropped. A change whose only incompatibility already
travels a designed, legible, per-artifact refusal path (a rejected
Result for one request, or the drop of one Notice under
Section 7.1) does not require one. Changing the major for such a change
would make every counterpart on the old major refuse all traffic,
including traffic the change does not affect, which is the failure the major
exists to prevent. Revisions of this document are not wire versions: a revision
that requires no new wire major leaves contract_version unchanged.¶
Why the wire version is "2". The wire version starts at
"2", not "1", because the value "1" is already taken.
Before this document, implementations exchanged an earlier, incompatible shape
under "1": it had no attachments, and its envelopes were closed. Reusing "1" for this
document's shapes would give one major two incompatible meanings, and would
defeat the fail-closed rule above exactly where it is needed: a consumer of the
old shape would accept a document it cannot process. "2" is also what
current implementations emit and check. Appendix L records the
history. The authors would be glad of the community's view on whether a reset is
nonetheless worth its cost.¶
Change control. A capability change starts with a change to this document, its schemas, and its fixtures, before either side implements it. Neither a Runtime nor a Connector may invent a wire member locally. A change that breaks an existing fixture is taken deliberately by both sides, and requires a new wire major only under the rule above.¶
contract_version is REQUIRED on the Submit Request, the Deliver
Notice, the Inbound Message, the Result, the Peer Notice and the Fleet Roster.
It is OPTIONAL on the
Binding Record (Section 9.1) and the Peer Directory, and the signed
Peer Statement and Control Notice carry their own version member, v,
instead. The rest of this table reflects what
implementations emit today; specified-but-unbuilt enrichment stays
optional, and tightening any of it to required would be a future major
bump.¶
| Artifact | Required | Optional (specified; may be absent) |
|---|---|---|
| deliver-notice |
contract_version, notice_id, ts, kind, message{id, from, subject, preview, mailbox}
|
message.{task_id, thread_id, in_reply_to, references, sender_standing, verdict, provenance}, message.attachments[] (items require filename, media_type, size_bytes, disposition; sha256 and content_ref optional, gated to disposition == "clean") |
| submit-request |
contract_version, req_id, draft{to, subject, body_text}
|
agent_id, ts, in_reply_to, draft.{cc, reply_to_message_id}, draft.attachments[] (items require filename, media_type, size_bytes, sha256) |
| result |
contract_version, req_id, outcome
|
detail, reason_code, ts, recipients, message_id, job_id, attestation (requires form; token and claims optional), attachments[] (items require index, outcome) |
| inbound-message |
(the artifact itself is OPTIONAL — Section 7.3); if present: contract_version, notice_id, body_text
|
id, date, from, to, cc, subject, attachments[] (same Descriptor rules as deliver-notice) |
| binding-record |
(the file itself is OPTIONAL — Section 9); if present: agent_id, address, provisioned_ts
|
label; contract_version (optional — if present, pinned to "2") |
| directory |
(the artifact itself is OPTIONAL — Section 10); if present: router, written_at, delegate_to, mail_to
|
contract_version (if present, pinned to "2") |
| peer-notice |
(peer-origin profile — Section 12.2) contract_version, notice_id, ts, kind: "peer", message{id, from, subject, preview, mailbox: "peer"}
|
message.{in_reply_to, references, task_id, thread_id, sender_standing, provenance, verdict, sender_exposure, attachments}; in_reply_to is REQUIRED on a reply (Section 12.5). The body spool and Sidecars are as for deliver-notice. |
| peer-statement |
(between hosts — Section 12.6.1) v, key_id, kind, id, from, to, ts, subject, body_sha256, attachments; carried in exactly one AMAP-Peer-Statement field with the tags k, s and b
|
in_reply_to, sender_exposure, sender_since
|
| peer-control |
(between hosts — Section 12.6.8) v, key_id, kind, id, from, to, ts, about, outcome, cause; carried in exactly one AMAP-Peer-Control field with the tags k, s and b
|
policy_code (REQUIRED with cause policy) |
| roster |
(the artifact itself is OPTIONAL — Section 11); if present: contract_version, router, written_at, members[] (items require address, state) |
interval_s
|
inbound/notices/processed/ is an OPTIONAL directory convention
(Section 4.1), not a protocol artifact: it carries no schema, and no
fixture exists or is needed for it.¶
Emission of contract_version on the artifacts that require it is
mandatory. An implementation not yet emitting it has an implementation
conformance gap, not a license from this document.¶
The fixtures are the other side. fixtures/ holds golden artifacts,
and a producer or consumer proves conformance by passing them with no
counterpart present (Appendix J). That is what makes independent
implementation practical: neither side needs the other to exist in order to
demonstrate conformance.¶
Not every obligation in this document is wire-shaped. A behavioral obligation — one about how an implementation acts against a filesystem, rather than about the shape of a document — lies outside the fixture gate by construction and is checked operationally instead. Appendix J lists every obligation of this kind that a green run does not prove.¶
This document defines two conformance classes. An implementation MAY claim both. Claiming either without the corresponding fixture run, and without the prescribed operational check for that class's behavioral obligations, is not conformance.¶
Runtime conformance class. An implementation claiming Runtime conformance:¶
MUST produce Deliver Notice documents valid against the deliver-notice schema (Appendix C) and Result documents valid against the result schema (Appendix B); MAY produce Inbound Message documents, which when produced MUST be valid against the inbound-message schema (Appendix D); and MAY produce a Binding Record valid against the binding-record schema (Appendix E).¶
MUST consume Submit Request documents: it MUST accept every valid
request-* fixture and MUST reject every invalid request-* fixture.¶
MUST demonstrate its producing side by confirming that its own emitted
Notices, Results, and Inbound Messages satisfy the same schemas the
suite's valid notice-*, result-*, and message-* fixtures satisfy —
including the three fixtures carrying unrecognized members. A Runtime
MUST NOT itself emit unrecognized members, but its parser, exercised by
those fixtures, is what a Connector conformance run depends on it never
having to special-case. It MUST also confirm that any Binding Record it
writes satisfies the valid identity-* shape.¶
MUST uphold the filesystem and policy obligations of Sections 4 through 8 that no document validator can observe. Appendix J lists them explicitly as what a green fixture run does not prove, and they are obligations of the class regardless — write-side path discipline (the conjunctive parent-chain-pinning, no-follow-while-building, and non-following-final-component rule), ingest by copy, read-side open discipline, and single-writer/single-drainer chief among them.¶
Connector conformance class. An implementation claiming Connector conformance:¶
MUST produce Submit Request documents valid against the submit-request
schema (Appendix A), and MUST NOT emit an outbound content_ref
(Section 5.1).¶
MUST consume Deliver Notice, Inbound Message, and Result documents: it
MUST accept every valid notice-*, message-*, and result-* fixture —
including those carrying unrecognized members or unrecognized
advisory-enum values — and MUST reject, meaning drop rather than
surface, every invalid notice-* fixture except the version-refusal
one, which it MUST instead refuse via the distinct version-refusal path
(Section 13.1): never folded into the drop path, and never surfaced to
the Agent either.¶
Section 7.1's drop rule is two independent conditions, not a closure
over schema validation. "Fails schema validation for any reason other
than an unrecognized member" (check 1) covers eleven of the twelve other
invalid notice-* fixtures — missing-required-member, wrong-kind,
enum, pattern, and conditional violations alike. The twelfth is
schema-valid on its own, producing zero errors from the gate's schema
validator, and is caught only by the content_ref index and prefix
binding (check 2, Section 7.2), a named post-check the schema cannot
express. A Connector conformance claim requires running both checks;
schema validation alone does not close this set.¶
Accepting the clean-Descriptor fixture — a Descriptor asserted clean
and carrying content_ref and sha256 — is part of this obligation, and
acceptance means relaying the Notice, not fetching the bytes: a Connector
that never dereferences content_ref satisfies it (Section 7.2).¶
MUST return "not resolved" for a body whose Inbound Message is absent, and MUST NOT attempt any fallback fetch (Section 7.3).¶
MUST tolerate a Binding Record that is absent, and MUST NOT crash or otherwise misbehave on one that is hostile or malformed (Section 9.1).¶
MUST NOT require write access anywhere under inbound/, demonstrated by
the operational check below rather than by a fixture (Section 4.1).¶
MUST demonstrate its producing side by confirming that its own emitted
requests satisfy the same schema the suite's valid request-* fixtures
satisfy, and MUST uphold the write-ordering, atomicity, and
read-discipline obligations of Section 4.1, which the fixtures cannot
check.¶
Peer-origin sub-classes. The peer-origin profile (Section 12) adds two sub-classes, each requiring the corresponding class above plus the profile's fixtures and operational checks. Claiming either without both is not conformance.¶
An implementation claiming Runtime + peer-origin conformance, in addition to the Runtime class:¶
MUST produce Peer Notices valid against the peer-notice schema
(Appendix F), and MUST confirm that its own emitted Peer Notices
satisfy the same schema the valid peer-* fixtures satisfy;¶
MUST refuse to emit into peer/ any document that would fail that
schema, and MUST NOT emit a peer document into inbound/
(Section 12.2);¶
MUST uphold the operational obligations of the profile: the
write-authority partition, proven by the write probe, and verification placement
(Section 12.3); a Runtime-asserted peer from
(Section 12.4); in_reply_to only when resolved, and
refusal rather than stripping (Section 12.5); the intake
location, and hashing of bytes by the verifier itself
(Section 12.6); and sender_exposure never settable by a
sender (Section 12.7);¶
if it verifies peer traffic from other hosts, MUST reach every step's expected outcome in the signed cases, and MUST pass the Ed25519 vectors through the implementation it ships (Appendix J).¶
An implementation claiming Connector + peer-origin conformance, in addition to the Connector class:¶
MUST accept every valid peer-* fixture and MUST reject every
invalid peer-* fixture when read from peer/notices/, and MUST
reject invalid/notice-kind-peer.json when read from
inbound/notices/;¶
MUST derive the origin class from the tree alone (Section 12.3), and MUST act on a peer message at most once, holding rather than fanning out when it cannot identify a single target (Section 12.4);¶
MUST tolerate unknown members on a Peer Notice, whose envelope is open (Section 13.1).¶
Read-only inbound tolerance — the prescribed operational check. Run
the Connector against an inbound tree it cannot write — a genuinely
read-only mount, not merely cleared permission bits, since a same-uid Agent
can change a mode it owns — and confirm that it starts, relays every
pending Notice, resolves bodies, and submits outbound requests, without
requiring any write under inbound/ to succeed.¶
This section gathers the threats this protocol's obligations exist to address. It introduces no new mechanism: apart from the rule for special-use domain names (Section 14.16), which is stated only here, every requirement referenced here is stated normatively in Sections 4 through 13. The organizing principle throughout is the invariant of Section 3.2 — enforcement is Runtime-side, and no property depends on the Agent or its Connector behaving.¶
The threat actor assumed throughout is a fully compromised Agent with write access to its own Namespace, running as a uid that owns the files it creates, able to act concurrently with the Runtime, and able to observe or predict the filenames the Runtime is about to use.¶
An identifier drawn from a file's contents MUST NOT name a write path
(Section 4.1). A Runtime that trusted a req_id member inside a
request body to name the path its Result is written to would let the Agent,
which controls that member, direct a Runtime-privileged write anywhere the
Runtime can reach. The
obligation is that the Runtime derives identifiers from the validated
filename, over a restricted character set, and re-derives every path from
data it controls.¶
The same reasoning is why inbound Sidecar directories and the body spool
are keyed on the Runtime-minted notice identifier rather than the provider
message.id: the provider identifier is external, arbitrary, and not
character-set-safe, and using one as a path component would hand the choice
of path to the sender. The schemas pin notice_id and req_id to
character sets that are safe as path components; message.id is
deliberately unconstrained, because it is never used as one.¶
A shared filesystem between a trusted and an untrusted party makes every path-based check racy. The Agent can replace any component of a path it owns with a symlink, at any moment, including between a Runtime's check and the Runtime's subsequent act.¶
Section 4.1 states the write-side discipline as three conjunctive requirements, and the conjunction is the point: each leg closes a gap the other two leave open.¶
Pinning only the final component leaves the parent swappable, and rename and directory-creation calls have no no-follow flag at all — so a bare "do not follow symlinks on writes" rule is not sufficient.¶
Pinning the parent chain without refusing symlinks while acquiring it means the pinned descriptor may point at whatever the Agent planted before the Runtime opened it — including across a Runtime restart, when the chain is re-opened.¶
Pinning the whole chain correctly still says nothing about the final
filename. Every Runtime-written filename in the layout tree is
predictable or observable to the Agent, so a pre-planted symlink at that
name receives the Runtime's write with the Runtime's privileges unless
the final open refuses to follow it and, where the file is newly created,
refuses to reuse an existing name at all (O_EXCL).¶
The append-only audit log is the one write with no .tmp sibling to lean
on, and it is called out separately for two reasons. O_EXCL cannot apply
to a file that legitimately already exists, so the final-component
protection there is a no-follow, append-mode, non-blocking open plus an
fstat through the resulting descriptor. The non-blocking part is
load-bearing rather than a performance nicety: a no-follow flag does not
apply to a FIFO, because a FIFO is not a symlink, and opening a FIFO for
writing blocks until a reader appears. An Agent that plants a FIFO at that
filename therefore stalls the Runtime's drain indefinitely — a denial of
service that the exclusive-create branch happens to refuse for free, and
that this branch must refuse explicitly.¶
The read side mirrors all of it (Section 4.1): per-component no-follow
opens or an equivalently pinned walk, a non-blocking open, an fstat
through the open descriptor rather than a pre-open stat (which is itself
a race), refusal of a multiply-linked file, and enforced size and count
caps. The caps are not optional hardening: the too_large Result value
presumes one, and without them an Agent can present an unbounded Sidecar or
an unbounded request and exhaust the Runtime.¶
Even with every open performed correctly, validating a file in place and then using it is a race: the Agent can rewrite the bytes between the check and the use, so the document that passed policy is not necessarily the document that gets composed and sent. The obligation that closes this is ingest by copy (Section 4.1) — the Runtime copies the request and every Sidecar into Runtime-private Staging outside the Volume, and validates, evaluates policy on, and composes exclusively from that private copy, never re-reading an Agent-writable path after a decision that depends on its content.¶
This is also what keeps the trust model honest. Without it, the property "the request the Runtime approved is the request the Runtime sent" would rest on the Connector not rewriting a published file — an Agent-side obligation carrying a Runtime-side safety property, which the invariant of Section 3.2 forbids. With it, that Connector obligation is pure well-formedness: a Runtime MAY treat an observed post-publish change as malformed, but nothing depends on the Connector honoring it.¶
Every atomicity guarantee in this protocol — the .tmp-then-rename
discipline, the commit sentinel, the derivation of req_id from the
maximum of the pending, processed, and result sets — assumes a single
writer and a single drainer per Namespace, and none of them provides
mutual exclusion (Section 4.1). Two concurrent drains can deliver the same
request twice; two concurrent writers can collide on a req_id and clobber
a published request, producing a verdict that names a request that no
longer exists as written. The exclusion mechanism lives outside this
protocol, and an implementation that does not provide one is not
conformant, because behavior under violation is unspecified rather than
merely degraded.¶
Attribution is the Namespace a file arrived in, never an identifier inside
the file (Section 4.1). A compromised Agent can write any agent_id it
likes into a request; the member exists only as a cross-check, and a
Runtime MUST reject a mismatch rather than believe it. Structural isolation
is what actually enforces this: a Connector scoped to its own Namespace
cannot name another Agent's.¶
The Binding Record deserves the same skepticism from the other direction (Section 9.1). It sits on an Agent-writable Volume, so its contents are untrusted data, it grants nothing, and it is never consulted for enforcement.¶
Inbound mail is an injection vector aimed at the Agent's reasoning, and this protocol's posture is that every sender-originated string is untrusted content: bodies, subjects, previews, threading references, sender display names, and attachment Descriptors alike (Section 7.1).¶
Two cases are worth restating because they are the ones most often assumed safe.¶
message.from is attacker-controlled display data. Its display-name
half is chosen freely by the sender, so a value such as
"security@yourbank.example <attacker@evil.example>" is entirely legal and
renders convincingly. It MUST NOT be used for a trust decision or shown to
a human or an Agent as verified identity. Verified sender identity is only
ever what the Runtime summarizes into sender_standing and verdict, or,
on a Peer Notice, the message.from the Runtime asserts
(Section 12.4).¶
An attachment filename is an injection payload. A Descriptor is
metadata about a file the Agent may never receive the bytes of, and a
clean Disposition asserts something about bytes, never about the
truthfulness of the strings describing them. A quarantined file named to
look like an instruction reaches the Agent's context intact even though its
bytes were withheld. media_type is likewise a declaration, which is why
policy is governed by the Runtime's own sniffing of the bytes rather than
by the declared type.¶
verdict, sender_standing, and provenance are advisory metadata for
routing and presentation. None of them is a license for an Agent to obey
body content, and this document deliberately does not define what an Agent
should do with a message — that is Connector and deployment policy.¶
Recipient policy is the exfiltration boundary, and it is a Runtime decision
(Section 5.1). The Agent's draft.to and draft.cc are requests, not
instructions. Two obligations make the boundary mechanical rather than
best-effort: the Runtime MUST evaluate policy over exactly the recipient
set it will transmit to, and it MUST NOT transmit to any address not
present in those members. Without the first, policy can be evaluated over a
parse of the recipients that differs from the one used for transmission;
without the second, a Runtime-side addition (a bcc, an archive address)
escapes the evaluated set.¶
The narrow addr-spec profile exists to make the evaluation mechanical. Display names, address lists inside a single element, quoted local parts, comments, IP literals, and folding whitespace are precisely the forms that make "does this address match the allowlist" a parsing problem with divergent answers — the classic confusion in which one parser sees the allowlisted address and the transmitting parser sees the attacker's. The profile is deliberately narrower than [RFC5322] for that reason, and the cost is disclosed: some legitimate [RFC5322] addresses cannot be expressed under this document.¶
Header injection is the second half. Any Agent-authored string that reaches
the composed message can attempt to introduce a header boundary with CR,
LF, or NUL. The schema patterns are the first line of defense, but a
Runtime MUST also reject or strip those characters at composition time,
because a document can reach composition by a path where schema validation
was bypassed upstream. The two fields most easily overlooked are the ones
composed directly into headers rather than into a body or display
context: reply_to_message_id, which becomes an In-Reply-To: header, and
an attachment's media_type, which becomes a MIME part's Content-Type:
[RFC2045].¶
sha256 on an outbound attachment Descriptor is computed by the Connector
over bytes the Connector wrote (Section 5.1). The Agent controls both, so
a matching digest proves only that the request is internally consistent.
It catches corruption and interrupted writes; it authenticates nothing. A
Runtime MUST NOT treat a digest match as evidence about the content's
safety, and MUST apply its own policy — sniffing the bytes, applying type
and size rules — to the staged copy regardless.¶
The inbound direction is weaker still and is labelled as such: inbound
size_bytes is provider-declared and advisory, not Runtime-verified
(Section 7.2).¶
Inbound metadata is gated so that withholding bytes does not become an
information channel. sha256 on an inbound Descriptor is emitted only
where the Runtime asserts clean (Section 7.2). Publishing a digest for a
withheld attachment would let an Agent confirm whether a guessed file was
the one quarantined — a confirmation oracle — while providing no integrity
value at all, since the Agent has no bytes to check it against.¶
The same principle governs quarantined mail generally: it produces no Notice at all, rather than a Notice announcing that something was withheld.¶
The access gate in this protocol is the absence of bytes, not a flag
inside a document (Section 4.1). A Runtime MUST NOT place non-clean bytes
in an Agent's Namespace; content_ref is the grant, and it exists only
where bytes were published. An Agent that ignores every Disposition it is
shown still cannot read what was never written.¶
The same shape appears for bodies: a withheld or never-delivered message is simply not spooled, and a read against a missing artifact returns "not resolved," with no fallback fetch available to the Connector (Section 7.3). Because retention may also remove an artifact (Section 4.2), "retired" and "never spooled" are indistinguishable from the Connector's side — which is a deliberate property, not an ambiguity to be resolved by a new signal.¶
The content_ref binding rules (Section 7.2) close the corresponding
path-confusion risk: a consumer derives the byte path from the enclosing
notice_id and the array index rather than from the content_ref string,
and refuses any reference that does not match the pinned pattern or points
outside its own inbound tree. Otherwise a Descriptor could name another
Notice's Sidecar directory — including one belonging to a different
delivery whose bytes the Agent was never granted.¶
A consumer reading a higher major than it speaks fails closed, and the version check runs before schema validation so that "wrong version" and "malformed" remain distinguishable (Section 13.1). Folding the two together is how a protocol acquires silent, undiagnosable mail loss.¶
The unknown-member tolerance rule is direction-aware precisely because
tolerance has a security cost in one direction and a safety benefit in the
other. On Runtime-authored artifacts, strictness defends nothing — the
producer is trusted, and an attacker able to author a Notice at all (a
writable inbound tree) can author a schema-valid one — while its failure
mode is silent mail loss. On the Agent-authored Submit Request, closure is
doing real work: it is what makes an outbound content_ref, or any other
smuggled member, a hard rejection rather than a member the Runtime might
one day interpret. A Runtime therefore MUST keep rejecting unrecognized
members inbound from an Agent, and its rejection is legible — a rejected
Result — rather than silent.¶
The value-level tolerance is scoped to the two explicitly advisory
vocabularies for the same reason: kind, outcome, and disposition gate
processing paths and byte publication, and treating an unrecognized value
in any of them as opaque would let a producer's new value silently select a
consumer's default path.¶
A read-only inbound mount is the mechanism that makes a delivered Notice unforgeable (Section 4.1). Permission bits alone do not achieve it: a same-uid Agent can change a mode it owns, so an inbound tree protected only by bits is writable by the attacker in practice. Deployments that leave inbound writable should understand what they are accepting — an Agent can then author a schema-valid Notice for mail that never arrived, or alter one that did.¶
This is also why the tolerance rule does not weaken the inbound direction: in the writable-inbound posture, the attacker's forgery is schema-valid anyway, so schema strictness was never the control. The control is the mount.¶
Correspondingly, a Connector MUST NOT require write access under
inbound/, and consumption tracking is Connector-private state kept
elsewhere (Section 4.1). A Connector that crashes on a read-only inbound
tree is broken against any Runtime enforcing inbound integrity.¶
Deletion rights are named per artifact class (Section 4.2) because an unnamed right becomes either unbounded growth or an accidental data-loss path. Two hazards are worth restating.¶
A Result deleted before it is read is a lost verdict. No signal in this protocol tells a Runtime that a Connector has read one, so Runtime-side retention SHOULD be age-based and generous.¶
Deletion interacts with req_id monotonicity. A Connector exercising
its deletion rights in processed/ or results/ removes exactly the
state from which the next req_id is derived, and MUST preserve
monotonicity by other durable means. Symmetrically, a Runtime MUST NOT
treat processed/ as durable state — for deduplication, for instance —
because the Connector may delete there. The Runtime's own record of what
it sent belongs outside this protocol entirely.¶
A green conformance run proves that documents have the right shape. It proves nothing about a filesystem. The following are Runtime or Connector obligations that no document validator can observe, and a reviewer assessing an implementation must check them another way (Appendix J lists them too):¶
Sidecar file existence, and the binding between an ordinal and a directory entry on disk.¶
Whether size_bytes and sha256 match the bytes actually on disk.¶
Display sanitization of filename, and its never-a-path invariant.¶
The keying of inbound Sidecar directories on the Runtime-minted notice identifier.¶
The location of Runtime-private Staging, outside the mounted Volume.¶
Write ordering and commit-sentinel atomicity.¶
Caps, allowlists, and quotas.¶
The absence of a spooled body for a quarantined or blocked message.¶
A Connector's tolerance of a read-only inbound tree.¶
Ingest by copy.¶
Write-side path discipline, in all three of its conjunctive legs.¶
Read-side open discipline.¶
Single writer and single drainer per Namespace.¶
Compose-time rejection or stripping of CR, LF, and NUL.¶
Two of these deserve emphasis as trust-model statements rather than as
testing notes. First, disposition == "clean" is the Runtime's
assertion, and this document deliberately does not specify the basis for
it; a consumer that treats clean as a proof rather than as an assertion
has misread the trust model. Second, this protocol cannot make a Runtime
trustworthy — it can only ensure that everything the Agent contributes is
inert until a Runtime acts on it, and that whatever the Runtime asserts is
legible and attributable to the Runtime.¶
outbound/ext/<name>/ (Section 4.1) is Connector-owned and Agent-writable,
and this document specifies no shape for anything under it. Its <name>
is a stable, opaque Connector id (Section 4.1), so a Runtime's
out-of-band agreement with a Connector survives that Connector's
repository being renamed. Nothing security-relevant may be built on it.
Its first use, a per-Notice outcome file, is written by the receiving
Agent's own uid and is therefore forgeable by construction: a receiving
Agent can claim a delivery that did not happen, deny one that did, or
suppress a refusal. An outcome is a statement by the receiving side about
what it did with a Notice; a party MUST NOT treat an outcome as proof of
delivery or of non-delivery, and the Runtime's own delivery record is
authoritative over it. A Runtime that reads outcomes may let them change
what a sender is told or what an operator sees; it MUST NOT let them
change what its own record says happened. There is deliberately no inbound
extension space, because an inbound tree is a Runtime assertion about
origin (Section 7.1) and a Connector cannot know what a vendor-named
assertion means.¶
Deployments commonly give Agents addresses under names that are not part of
the public DNS: special-use domain names listed in the registry established
by [RFC6761] (such as .test, .local, .home.arpa and .onion),
and names reserved for private use, such as .internal. A Runtime MUST NOT
hand mail addressed to such a name to a mail system outside the deployment,
whatever its recipient policy allows: doing so leaks fleet-internal
addresses, and the mail they carry, onto the public Internet. A deployment's
own mail system that is authoritative for such a name may carry it, and then
MUST NOT relay it onward.¶
The profile adds a tree that a consumer may treat as authenticated origin, so its security rests on who can write that tree and on what a verifying Runtime accepts from another host.¶
Forgery of the peer tree. A component that parses mail
from an open transport is the most exposed component in a Runtime. If it could
write peer/, any sender able to reach it could forge authenticated peer
origin. The write-authority partition and the placement of verification
(Section 12.3) exist to prevent that, and are checked by a
write probe rather than by configuration review.¶
Key compromise. A Runtime's signing key speaks for every Agent in that Runtime's domains. An attacker holding it can place peer messages, as any of those Agents, at every Runtime that configures the key, until the key is revoked; the key-domain check (Section 12.6.3) confines the damage to the compromised Runtime's own domains. Keeping the key out of every Agent Namespace and out of the admitting component, using it for nothing else, and supporting rotation (Section 12.6.7) are the mitigations. Because replay records are scoped to the Runtime rather than the key, rotation does not reopen a replay window.¶
Replay and clocks. A statement is a bearer of authenticated origin for as long as it is fresh. The replay store, the high-water clock H and the recovery floor (Section 12.6.5) bound how long that is, including across a clock that steps backwards and a store that is lost. The profile does not promise exactly-once delivery, and says so; a deployment that needs it beyond the retention period has to forbid re-signing.¶
Parsing hostile input. The verifier reads data an attacker chose. It verifies the signature over the exact bytes received before parsing anything, defines no canonicalization (so there is no second parse to disagree with the first), refuses duplicate JSON member names, and receives the message already reduced to a candidate (Section 12.6.2), so that it never parses MIME itself. Oversize and invalid fields are carried as markers so that a malformed second field cannot be silently removed.¶
Backscatter and loops. A Control Notice is sent only after the statement has been authenticated, only to the configured Runtime address of the key's owner, never about another Control Notice, and at most once per record (Section 12.6.8). An unauthenticated message therefore cannot direct a Runtime to send mail to a third party, and two Runtimes cannot be made to answer each other indefinitely.¶
Authenticated is not trustworthy. A verified peer is a
same-principal Agent, which may itself have read hostile external mail and be
relaying it. The tree authenticates who, never what
(Section 12.8). sender_exposure
(Section 12.7) lets a Runtime say whether the sender
was exposed to external mail, and absence of the stamp means that nothing was
assessed.¶
Exposure in transit. Between hosts, the statement travels in a header field. Its subject, addresses and attachment names are encoded rather than encrypted, and are visible to every mail system on the path, as the rest of the message is. The profile provides integrity and origin, not confidentiality; a deployment that needs confidentiality between hosts has to obtain it from the transport.¶
The peer-origin profile (Section 12) defines two header fields carried in mail between Runtimes. IANA is requested to register them in the "Provisional Message Header Field Names" registry, following the procedure of [RFC3864]:¶
| Header Field Name | Protocol | Status | Reference |
|---|---|---|---|
| AMAP-Peer-Statement | provisional | Section 12.6.2 of this document | |
| AMAP-Peer-Control | provisional | Section 12.6.8 of this document |
Change controller: IETF.¶
This document creates no registry. Several of its values are candidates
for one, and the authors would welcome the community's view on whether IANA
should govern them: the values of contract_version
(Section 13.1), whose fail-closed semantics make an unambiguous
assignment important; the open reason_code vocabulary of the Result
(Section 6.1); and the closed outcome and cause vocabularies of the
Control Notice (Section 12.6.8).¶
(Normative.) The schema below is written in the JSON Schema 2020-12
dialect [JSON-SCHEMA-2020-12] and governs the artifact defined in
Section 5. This is the Agent-authored artifact, and its envelope is closed
at every level: an unrecognized member is a hard rejection, surfaced as a
rejected Result rather than as a silent drop (Section 13.1).¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/submit-request.schema.json",
"title": "submit-request",
"description": "agent -> runtime: an inert outbound request. AMMP\
, section \"Outbound: Submit Request\". Agent-authored: this doc\
ument's envelope stays CLOSED (additionalProperties:false) at ev\
ery level per the asymmetry of section \"Versioning\" — an unrec\
ognized member here is a designed, legible `rejected` refusal, n\
ever a silent drop.",
"type": "object",
"required": ["contract_version", "req_id", "draft"],
"additionalProperties": false,
"properties": {
"contract_version": { "const": "2" },
"req_id": {
"type": "string",
"minLength": 1,
"description": "monotonic per namespace; filename-safe (path a\
uthority is the filename, not this field — the filename-authorit\
y-for-paths obligation of section \"Obligations\").",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$"
},
"agent_id": { "type": "string", "minLength": 1, "pattern": "^[^\\
u0000-\u001f\u007f]*(?!\\n)$" },
"ts": { "type": "string", "minLength": 1, "pattern": "^[^\u0000-\
\u001f\u007f]*(?!\\n)$" },
"in_reply_to": { "type": "string", "pattern": "^[^\u0000-\u001f\\
u007f]*(?!\\n)$" },
"draft": {
"type": "object",
"required": ["to", "subject", "body_text"],
"additionalProperties": false,
"properties": {
"to": {
"type": "array",
"items": {
"type": "string",
"description": "one bare addr-spec per element (no displ\
ay name, address list, quoted local part, comment, IP literal, o\
r folding whitespace — deliberately narrower than RFC 5322: thos\
e forms are exactly what make allowlist evaluation non-mechanica\
l). EAI/SMTPUTF8 is out of scope at this version.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9\
.-]+(?!\\n)$"
},
"minItems": 1
},
"cc": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9\
.-]+(?!\\n)$"
}
},
"subject": { "type": "string", "pattern": "^[^\u0000-\u001f\\
u007f]*(?!\\n)$" },
"body_text": { "type": "string" },
"reply_to_message_id": { "type": ["string", "null"], "patter\
n": "^[^\u0000-\u001f\u007f]*(?!\\n)$" },
"attachments": {
"type": "array",
"items": {
"type": "object",
"required": ["filename", "media_type", "size_bytes", "sh\
a256"],
"additionalProperties": false,
"properties": {
"filename": { "type": "string", "minLength": 1, "pat\
tern": "^[^\u0000-\u001f\u007f]*(?!\\n)$" },
"media_type": { "type": "string", "minLength": 1, "pat\
tern": "^[^\u0000-\u001f\u007f]*(?!\\n)$" },
"size_bytes": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-9\
]{64}(?!\\n)$" }
}
}
}
}
}
}
}
(Normative.) JSON Schema 2020-12 [JSON-SCHEMA-2020-12]; governs the
artifact defined in Section 6. Runtime-authored, and therefore open at
every level: a consumer tolerates unrecognized members (Section 13.1). The
attachments[i].index == i binding stated in Section 6.1 is not
expressible here and is enforced as a named post-check by the conformance
gate (Appendix J).¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/result.schema.json",
"title": "result",
"description": "runtime -> agent: verdict for a submit-request. AM\
MP, section \"Outbound: Result\". Runtime-authored: this documen\
t's envelope is OPEN (no additionalProperties:false) at every le\
vel per the tolerance rule of section \"Versioning\" — a consume\
r MUST ignore members it does not recognize; emitting an unrecog\
nized member remains producer non-conformance.",
"type": "object",
"required": ["contract_version", "req_id", "outcome"],
"properties": {
"contract_version": { "const": "2" },
"req_id": { "type": "string", "minLength": 1 },
"outcome": {
"type": "string",
"enum": ["accepted", "queued_for_human", "rejected"]
},
"detail": { "type": ["string", "null"] },
"reason_code": { "type": "string", "minLength": 1 },
"ts": { "type": "string", "minLength": 1 },
"recipients": { "type": "array", "items": { "type": "string" } },
"message_id": { "type": ["string", "null"] },
"job_id": { "type": ["string", "null"] },
"attestation": {
"type": "object",
"required": ["form"],
"properties": {
"form": { "type": "string", "enum": ["self-stamp", "verifi\
able"] },
"token": { "type": "string", "minLength": 1 },
"claims": { "type": "object" }
}
},
"attachments": {
"type": "array",
"items": {
"type": "object",
"required": ["index", "outcome"],
"properties": {
"index": { "type": "integer", "minimum": 0 },
"outcome": { "type": "string",
"enum": ["accepted", "dlp_held", "rejected_ty\
pe", "too_large"] },
"detail": { "type": ["string", "null"] }
}
}
}
}
}
(Normative.) JSON Schema 2020-12 [JSON-SCHEMA-2020-12]; governs the
artifact defined in Section 7. Runtime-authored and open at every level.
The two allOf conditionals implement the clean-gating of content_ref
and sha256 (Section 7.2); the content_ref index and prefix binding, by
contrast, is not expressible here and is enforced as a named post-check
(Appendix J).¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/deliver-notice.schema.json",
"title": "deliver-notice",
"description": "runtime -> connector: one notice per delivered mes\
sage. AMMP, section \"Inbound: Deliver Notice and Inbound Messag\
e\". Runtime-authored: this document's envelope is OPEN (no addi\
tionalProperties:false) at every level per the tolerance rule o\
f section \"Versioning\" — a consumer MUST ignore members it doe\
s not recognize; emitting an unrecognized member remains produce\
r non-conformance.",
"type": "object",
"required": ["contract_version", "notice_id", "ts", "kind", "messa\
ge"],
"properties": {
"contract_version": { "const": "2" },
"notice_id": { "type": "string", "minLength": 1, "pattern": "^[A\
-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$" },
"ts": { "type": "string", "minLength": 1 },
"kind": { "const": "deliver" },
"message": {
"type": "object",
"required": ["id", "from", "subject", "preview", "mailbox"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"from": { "type": "string", "minLength": 1 },
"subject": { "type": "string" },
"preview": { "type": "string" },
"mailbox": { "type": "string", "minLength": 1 },
"task_id": { "type": "string", "minLength": 1 },
"thread_id": { "type": "string" },
"in_reply_to": { "type": "string" },
"references": { "type": "array", "items": { "type": "string\
" } },
"sender_standing": {
"type": "string",
"description": "advisory, open vocabulary (known, agent-ti\
er, first-contact, read-only, ...). A consumer MUST tolerate a v\
alue outside this list, treating it as opaque — never grounds t\
o drop the notice; a runtime may add a value without a new wire \
major (section \"Versioning\")."
},
"provenance": {
"type": "string",
"description": "advisory, open vocabulary (external, inter\
nal, ...). A consumer MUST tolerate a value outside this list, t\
reating it as opaque — never grounds to drop the notice; a runti\
me may add a value without a new wire major (section \"Versionin\
g\")."
},
"verdict": {
"type": "object",
"properties": {
"auth": { "type": "string" },
"screen": { "type": "string" },
"entitlement": { "type": "string" }
}
},
"attachments": {
"type": "array",
"items": {
"type": "object",
"required": ["filename", "media_type", "size_bytes", "di\
sposition"],
"properties": {
"filename": { "type": "string", "minLength": 1 },
"media_type": { "type": "string", "minLength": 1 },
"size_bytes": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-\
9]{64}(?!\\n)$" },
"disposition": { "type": "string",
"enum": ["clean", "stripped", "quaran\
tined", "unscanned"] },
"content_ref": { "type": "string",
"pattern": "^[A-Za-z0-9_.-]+\\.attach\
ments/(0|[1-9][0-9]*)(?!\\n)$" }
},
"allOf": [
{ "if": { "required": ["content_ref"] },
"then": { "properties": { "disposition": { "const": \
"clean" } } } },
{ "if": { "required": ["sha256"] },
"then": { "properties": { "disposition": { "const": \
"clean" } } } }
]
}
}
}
}
}
}
(Normative.) JSON Schema 2020-12 [JSON-SCHEMA-2020-12]; governs the artifact defined in Section 7.3. Runtime-authored and open at every level. Its Descriptor rules, including the clean-gating conditionals, match the deliver-notice schema's.¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/inbound-message.schema.json",
"title": "inbound-message",
"description": "runtime -> connector: the full delivered message (\
incl. body_text), spooled at inbound/messages/notice-<id>.json, \
keyed on the runtime-minted notice-id (never the provider messag\
e.id). AMMP, section \"The Inbound Message (body spool)\". Runti\
me-authored: this document's envelope is OPEN (no additionalProp\
erties:false) at every level per the tolerance rule of section \\
"Versioning\" — a consumer MUST ignore members it does not recog\
nize; emitting an unrecognized member remains producer non-confo\
rmance.",
"type": "object",
"required": ["contract_version", "notice_id", "body_text"],
"properties": {
"contract_version": { "const": "2" },
"notice_id": { "type": "string", "minLength": 1, "pattern": "^[A\
-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$" },
"body_text": { "type": "string" },
"id": { "type": "string" },
"date": { "type": "string" },
"from": { "type": "string" },
"to": { "type": "string" },
"cc": { "type": "string" },
"subject": { "type": "string" },
"attachments": {
"type": "array",
"items": {
"type": "object",
"required": ["filename", "media_type", "size_bytes", "dispos\
ition"],
"properties": {
"filename": { "type": "string", "minLength": 1 },
"media_type": { "type": "string", "minLength": 1 },
"size_bytes": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{6\
4}(?!\\n)$" },
"disposition": { "type": "string",
"enum": ["clean", "stripped", "quarantine\
d", "unscanned"] },
"content_ref": { "type": "string",
"pattern": "^[A-Za-z0-9_.-]+\\.attachment\
s/(0|[1-9][0-9]*)(?!\\n)$" }
},
"allOf": [
{ "if": { "required": ["content_ref"] },
"then": { "properties": { "disposition": { "const": "cle\
an" } } } },
{ "if": { "required": ["sha256"] },
"then": { "properties": { "disposition": { "const": "cle\
an" } } } }
]
}
}
}
}
(Normative.) JSON Schema 2020-12 [JSON-SCHEMA-2020-12]; governs the artifact defined in Section 9. This envelope is closed, for the reason given in Section 9.1 — an anti-secret-leak lint rather than a compatibility surface.¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/binding-record.schema.json",
"title": "binding-record",
"description": "runtime/provisioner -> volume root: identity.json\
, the mailbox<->volume binding record. AMMP, section \"Binding R\
ecord\". INFORMATIONAL, NOT AUTHORITATIVE: the volume is agent-w\
ritable, so a consumer MUST treat contents as untrusted data an\
d MUST tolerate the file's absence. Unlike the other runtime-aut\
hored documents, this schema is closed (section \"Versioning\") \
— its closure is an anti-secret-leak lint, not a compatibility s\
urface.",
"type": "object",
"required": ["agent_id", "address", "provisioned_ts"],
"additionalProperties": false,
"properties": {
"contract_version": { "const": "2" },
"agent_id": { "type": "string", "minLength": 1 },
"address": { "type": "string", "minLength": 1 },
"provisioned_ts": { "type": "string", "minLength": 1 },
"label": { "type": "string" }
}
}
(Normative.) JSON Schema 2020-12 [JSON-SCHEMA-2020-12].
The first schema governs a Peer Notice, the artifact the peer-origin profile
(Section 12) places under <agent-id>/peer/notices/
(Section 12.2). Its envelope is Runtime-authored and therefore open
(Section 13.1). A document
with kind: "peer" found under inbound/notices/ fails the deliver-notice
schema (Appendix C), and a document with kind: "deliver" found under
peer/notices/ fails this one; both are dropped under the rule of
Section 7.1.¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/peer-notice.schema.json",
"title": "peer-notice",
"description": "runtime -> connector: one notice per peer message \
placed in <agent-id>/peer/notices/. AMMP, section \"The Peer-Ori\
gin Profile (Optional)\". Same layout as deliver-notice with kin\
d and mailbox pinned to \"peer\", message.from pinned to a bare \
addr-spec (the one runtime-asserted `from` in the protocol), mes\
sage.id / in_reply_to / references pinned to the notice_id chars\
et (the peer message id is bare on the wire; its Message-ID enco\
ding exists only inside a mail header, never in an AMMP document\
), plus the optional runtime-asserted message.sender_exposure. R\
untime-authored: envelope OPEN at every level per section \"Vers\
ioning\" — a consumer MUST ignore members it does not recognize\
. A document with kind \"peer\" found under inbound/notices/ fai\
ls deliver-notice.schema.json; a document with kind \"deliver\" \
found under peer/notices/ fails this schema: both are dropped un\
der the drop rule of section \"Field semantics and obligations\\
" (the profile's cross-lock, section \"The Peer Tree\").",
"type": "object",
"required": ["contract_version", "notice_id", "ts", "kind", "messa\
ge"],
"properties": {
"contract_version": { "const": "2" },
"notice_id": { "type": "string", "minLength": 1, "pattern": "^[A\
-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$" },
"ts": { "type": "string", "minLength": 1 },
"kind": { "const": "peer" },
"message": {
"type": "object",
"required": ["id", "from", "subject", "preview", "mailbox"],
"properties": {
"id": {
"type": "string", "minLength": 1,
"description": "the peer message id: minted by the sendin\
g runtime on accepting the submit, returned to the sender as res\
ult.message_id, >=128 bits of entropy, globally unique and ungue\
ssable. Bare on the wire under the notice_id charset; when carri\
ed in a mail header it is encoded as <id@domain> and decoded aga\
in before it enters any AMMP document.",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$"
},
"from": {
"type": "string", "minLength": 1,
"description": "bare addr-spec, runtime-asserted from the \
restricted write path or the verified cross-host statement, neve\
r copied from sender text. The one `from` in the protocol a cons\
umer may treat as authenticated origin.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-\
]+(?!\\n)$"
},
"subject": { "type": "string" },
"preview": { "type": "string" },
"mailbox": { "const": "peer" },
"task_id": { "type": "string", "minLength": 1 },
"thread_id": { "type": "string" },
"in_reply_to": {
"type": "string", "minLength": 1,
"description": "present iff the placing runtime resolved i\
t from its own ledger (a peer message id it issued or placed). R\
EQUIRED on a result by prose; a fresh task omits it. Bare peer i\
d, never the Message-ID-encoded form.",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$"
},
"references": {
"type": "array",
"items": { "type": "string", "minLength": 1, "pattern": "^\
[A-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$" }
},
"sender_standing": {
"type": "string",
"description": "advisory, open vocabulary; SHOULD be `agen\
t-tier` when set on a peer notice."
},
"provenance": {
"type": "string",
"description": "advisory, open vocabulary; redundant with \
the tree on a peer notice and MAY be omitted. Never grounds to d\
erive or override origin class."
},
"verdict": {
"type": "object",
"properties": {
"auth": { "type": "string" },
"screen": { "type": "string" },
"entitlement": { "type": "string" }
}
},
"sender_exposure": {
"type": "object",
"description": "runtime-asserted, never sender-settable (\
a submit-request carrying it is `rejected` by the closed envelop\
e; cross-host it is taken only from the verified statement). Abs\
ence means NOT ASSESSED, never `no exposure`. MUST NOT drive rou\
ting or treatment.",
"required": ["asserted_by", "external_mail_delivered", "wi\
ndow_start", "window_end"],
"properties": {
"asserted_by": { "type": "string", "minLength": 1, "patt\
ern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+(?!\\n)$\
" },
"external_mail_delivered": { "type": "boolean" },
"window_start": { "type": "string", "minLength": 1 },
"window_end": { "type": "string", "minLength": 1 },
"last_external_delivery_ts": { "type": "string", "minLen\
gth": 1 },
"derived_from": {
"type": "array",
"items": { "type": "string", "minLength": 1, "pattern"\
: "^[A-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$" }
}
},
"if": { "required": ["external_mail_delivered"], "properti\
es": { "external_mail_delivered": { "const": true } } },
"then": { "required": ["last_external_delivery_ts"] }
},
"attachments": {
"type": "array",
"items": {
"type": "object",
"required": ["filename", "media_type", "size_bytes", "di\
sposition"],
"properties": {
"filename": { "type": "string", "minLength": 1 },
"media_type": { "type": "string", "minLength": 1 },
"size_bytes": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-\
9]{64}(?!\\n)$" },
"disposition": { "type": "string",
"enum": ["clean", "stripped", "quaran\
tined", "unscanned"] },
"content_ref": { "type": "string",
"pattern": "^[A-Za-z0-9_.-]+\\.attach\
ments/(0|[1-9][0-9]*)(?!\\n)$" }
},
"allOf": [
{ "if": { "required": ["content_ref"] },
"then": { "properties": { "disposition": { "const": \
"clean" } } } },
{ "if": { "required": ["sha256"] },
"then": { "properties": { "disposition": { "const": \
"clean" } } } }
]
}
}
}
}
}
}
Between hosts, a peer message travels as mail carrying a signed Peer
Statement in one AMAP-Peer-Statement header field
(Section 12.6). The receiving Runtime verifies the statement's
bytes before parsing them, then validates them against the schema below, which is
closed at every level: these are signed bytes another Runtime acts on, not a
Runtime-authored document a Connector reads. The statement-* fixtures
(Appendix J) exercise the schema, and the signed cases exercise the
verification rules.¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/peer-statement.schema.json",
"title": "peer-statement",
"description": "runtime -> runtime: the signed statement that carr\
ies a peer message between hosts. AMMP, section \"Peer Traffic B\
etween Hosts\". The sending runtime signs the statement's exact \
UTF-8 bytes with Ed25519 and ships both in one AMAP-Peer-Stateme\
nt header field (section \"Carriage, and the Candidate the Verif\
ier Sees\"); the receiving runtime verifies the signature over t\
he received bytes before parsing, then validates against this sc\
hema. CLOSED at every level, unlike the runtime-authored notices\
: these are signed bytes another runtime acts on, a member the r\
eceiver does not know is a member it cannot bind to what it publ\
ishes, and the version member `v` is the only evolution path. A \
statement that fails this schema fails verification, and the mes\
sage is demoted to mail (sections \"Verification\" and \"Outcome\
s\"). Numbers are lexical integers (no fraction or exponent) an\
d strings contain no unpaired surrogate (section \"The Statement\
\", checked at check 5(b) of section \"Verification\"); the sche\
ma cannot express either, nor calendar validity, interval order\
, the entropy of `id`, or any binding to the message: those are \
the verification steps of section \"Verification\".",
"type": "object",
"additionalProperties": false,
"required": [
"v",
"key_id",
"kind",
"id",
"from",
"to",
"ts",
"subject",
"body_sha256",
"attachments"
],
"properties": {
"v": {
"type": "integer",
"const": 1
},
"key_id": {
"type": "string",
"description": "names one key in the signing peer's configure\
d key set; opaque, unique within that set, 1-64 characters.",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?!\\n)$"
},
"kind": {
"const": "peer"
},
"id": {
"type": "string",
"minLength": 1,
"description": "the peer message id (section \"Correlation an\
d Replies\"), bare. A sending runtime mints it under ^[A-Za-z0-9\
_][A-Za-z0-9_-]*$ (hex, or base64url re-minted if it would star\
t with '-'): the notice_id charset without '.', so its Message-I\
D encoding <id@domain> is a valid msg-id (section \"The Statemen\
t\"); the receiver publishes it as message.id.",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_-]*(?!\\n)$"
},
"in_reply_to": {
"type": "string",
"minLength": 1,
"description": "the peer message id this message answers (sect\
ion \"Correlation and Replies\"), bare, minted by the other runt\
ime under the same pattern. Absent on a fresh task. Resolved aga\
inst the receiver's ledger with the participants bound (section \
\"Correlation and Edges Between Hosts\").",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_-]*(?!\\n)$"
},
"from": {
"type": "string",
"minLength": 1,
"description": "bare addr-spec of the sending agent; its domai\
n MUST be one of the signing peer's configured domains.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+(?\
!\\n)$"
},
"to": {
"type": "string",
"minLength": 1,
"description": "bare addr-spec of the one receiving agent (sec\
tion \"Addressing\": a peer message is addressed to one agent).",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+(?\
!\\n)$"
},
"ts": {
"type": "string",
"description": "when the sending runtime signed: RFC 3339 UTC\
, whole seconds, upper-case T and Z, no offset. MUST be a real c\
alendar instant (section \"The Statement\").",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9\
]{2}Z(?!\\n)$"
},
"subject": {
"type": "string",
"description": "the subject the receiver publishes as message.\
subject; no control characters.",
"pattern": "^[^\u0000-\u001f]*(?!\\n)$"
},
"body_sha256": {
"type": "string",
"description": "SHA-256 of the UTF-8 encoding of the body_tex\
t the receiver will publish, lower-case hex.",
"pattern": "^[a-f0-9]{64}(?!\\n)$"
},
"attachments": {
"type": "array",
"description": "one entry per attachment, in order; empty whe\
n there are none.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"sha256",
"size_bytes",
"filename",
"media_type"
],
"properties": {
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}(?!\\n)$"
},
"size_bytes": {
"type": "integer",
"minimum": 0
},
"filename": {
"type": "string",
"minLength": 1
},
"media_type": {
"type": "string",
"minLength": 1
}
}
}
},
"sender_exposure": {
"type": "object",
"additionalProperties": false,
"description": "section \"Sender Exposure\", asserted by the s\
ending runtime; omitted when it made no assessment. Timestamps i\
n ts's format; window_start <= window_end; last_external_deliver\
y_ts present iff external_mail_delivered is true, and then withi\
n the window; asserted_by is the signing runtime's configured ru\
ntime address (section \"The Statement\"; checks 5(b) and 8 of s\
ection \"Verification\").",
"required": [
"asserted_by",
"external_mail_delivered",
"window_start",
"window_end"
],
"properties": {
"asserted_by": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-\
]+(?!\\n)$"
},
"external_mail_delivered": {
"type": "boolean"
},
"window_start": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:\
[0-9]{2}Z(?!\\n)$"
},
"window_end": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:\
[0-9]{2}Z(?!\\n)$"
},
"last_external_delivery_ts": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:\
[0-9]{2}Z(?!\\n)$"
},
"derived_from": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*(?!\\n)$"
}
}
},
"if": {
"required": [
"external_mail_delivered"
],
"properties": {
"external_mail_delivered": {
"const": true
}
}
},
"then": {
"required": [
"last_external_delivery_ts"
]
}
},
"sender_since": {
"type": "string",
"description": "OPTIONAL (section \"Optional: Sender Age, and \
Deployment Admission Policy\"): when the sending runtime first e\
nrolled the sending agent's identity (as that runtime defines id\
entity), from its durable registry; same format as ts and not la\
ter than ts. A runtime assertion, not a sender claim. Never rese\
t for a continuation of the same identity (restart, rebind, re-c\
reation, key rotation); a new enrolment starts a new value. A re\
ceiver enforcing a sender age keeps the last value per (runtime\
, from) and refuses an earlier one as inconsistent. Absence mean\
s NOT ASSERTED, never 'new'.",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9\
]{2}Z(?!\\n)$"
}
}
}
A receiving Runtime reports what it did with a statement it did not place
(refused, dropped or demoted) to the configured sending Runtime in a signed Control
Notice, carried in one AMAP-Peer-Control header field with the same grammar and
key (Section 12.6.8). Its kind is "control", so
neither document validates as the other; it is closed and carries no free text.¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/peer-control.schema.json",
"title": "peer-control",
"description": "runtime -> runtime: the signed control notice a re\
ceiving runtime sends to a configured sending runtime about wha\
t it did with an authenticated peer statement that it did not pl\
ace (refused, dropped, or demoted). AMMP, section \"Control Noti\
ces\". Carried in an AMAP-Peer-Control header field with the sam\
e k/s/b grammar as AMAP-Peer-Statement, signed with the same per\
-runtime key; `kind` is \"control\", so a control notice never v\
alidates as a peer statement and is never placed for an agent. C\
LOSED at every level, and it carries no free text: the receivin\
g runtime composes any agent-facing words itself (the delivery f\
ailure notice of section \"Correlation and Replies\"). The schem\
a cannot check that `about` names a peer message this runtime is\
sued to the notice's signer; that is the receiving runtime's che\
ck (section \"Control Notices\").",
"type": "object",
"additionalProperties": false,
"required": [
"v",
"key_id",
"kind",
"id",
"from",
"to",
"ts",
"about",
"outcome",
"cause"
],
"properties": {
"v": {
"type": "integer",
"const": 1
},
"key_id": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?!\\n)$"
},
"kind": {
"const": "control"
},
"id": {
"type": "string",
"description": "this notice's own id, minted like a peer messa\
ge id; replay-checked in the same store, scoped to the signing r\
untime.",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_-]*(?!\\n)$"
},
"from": {
"type": "string",
"description": "the signing (receiving) runtime's configured r\
untime address.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+(?\
!\\n)$"
},
"to": {
"type": "string",
"description": "the configured runtime address of the runtime \
that signed the statement this notice is about.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+(?\
!\\n)$"
},
"ts": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9\
]{2}Z(?!\\n)$"
},
"about": {
"type": "string",
"description": "the `id` of the peer statement this notice is \
about.",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_-]*(?!\\n)$"
},
"outcome": {
"enum": [
"refused",
"dropped",
"demoted"
]
},
"cause": {
"description": "why, from the outcome table of section \"Outco\
mes\". `policy` is a deployment refusal (for example a first-sig\
ht quarantine), named further by policy_code.",
"enum": [
"statement_value",
"key_domain",
"ts_window",
"exposure_signer",
"body_charset",
"body_digest",
"attachment_count",
"attachment_digest",
"message_id",
"duplicate",
"misrouted",
"unresolved_reply",
"no_edge",
"policy"
]
},
"policy_code": {
"type": "string",
"description": "deployment-defined token, only with cause `pol\
icy` (e.g. `quarantined`).",
"pattern": "^[a-z][a-z0-9_]{0,63}(?!\\n)$"
}
},
"allOf": [
{
"if": {
"required": [
"policy_code"
]
},
"then": {
"properties": {
"cause": {
"const": "policy"
}
}
}
},
{
"if": {
"properties": {
"cause": {
"const": "policy"
}
},
"required": [
"cause"
]
},
"then": {
"required": [
"policy_code"
]
}
},
{
"if": {
"properties": {
"outcome": {
"const": "refused"
}
},
"required": [
"outcome"
]
},
"then": {
"properties": {
"cause": {
"enum": [
"unresolved_reply",
"no_edge",
"policy"
]
}
}
}
},
{
"if": {
"properties": {
"outcome": {
"const": "dropped"
}
},
"required": [
"outcome"
]
},
"then": {
"properties": {
"cause": {
"enum": [
"duplicate",
"misrouted"
]
}
}
}
},
{
"if": {
"properties": {
"outcome": {
"const": "demoted"
}
},
"required": [
"outcome"
]
},
"then": {
"properties": {
"cause": {
"enum": [
"statement_value",
"key_domain",
"ts_window",
"exposure_signer",
"body_charset",
"body_digest",
"attachment_count",
"attachment_digest",
"message_id"
]
}
}
}
}
]
}
(Normative.) JSON Schema 2020-12 [JSON-SCHEMA-2020-12]; governs the optional peer
directory defined in Section 10, which a Runtime MAY publish at
<agent-id>/directory.json. The document is Runtime-authored and therefore
open (Section 13.1), and it is advisory: the schema pins its shape, never
what it predicts.¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/directory.schema.json",
"title": "directory",
"description": "runtime -> connector: the router's most recent wor\
d about which peers this agent may address, published per instan\
ce at <agent-id>/directory.json. AMMP, section \"The Peer Direct\
ory\". ADVISORY, NEVER AUTHORITATIVE: an address's presence is n\
ot a prediction that a submit to it will be accepted, nor its ab\
sence a prediction of refusal — authorisation is evaluated by th\
e runtime at submit time and only the result (section \"Outbound\
: Result\") reports what happened. A runtime MUST NOT read this \
file back as authorisation; enforcement stays the runtime's own \
graph check on every message. The file MAY be absent. Runtime-au\
thored: envelope is OPEN at every level per the tolerance rule o\
f section \"Versioning\" — a consumer MUST ignore members it doe\
s not recognize; emitting an unrecognized member remains produce\
r non-conformance.",
"type": "object",
"required": [
"router",
"written_at",
"delegate_to",
"mail_to"
],
"properties": {
"contract_version": {
"const": "2"
},
"router": {
"type": "string",
"description": "the publishing runtime's own address, one bar\
e addr-spec. Never appears in delegate_to or mail_to.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+(?\
!\\n)$"
},
"written_at": {
"type": "string",
"minLength": 1,
"description": "RFC 3339, the publishing runtime's clock. The \
only staleness signal a consumer gets; an honest marker, not a f\
reshness guarantee.",
"pattern": "^[^\\u0000-\\u001f\\u007f]*(?!\\n)$"
},
"delegate_to": {
"type": "array",
"description": "sorted addresses this agent may task — the aut\
horisation graph transposed for this instance. Always present; e\
mpty when the lane is closed. Never this agent's own address, an\
d never who may task THIS agent (an allowlist, withheld by const\
ruction).",
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+\
(?!\\n)$"
}
},
"mail_to": {
"type": "array",
"description": "sorted addresses on the mutual mail lane. Alwa\
ys present; empty when the lane is closed, which is the normal c\
ase for a delegation-only deployment.",
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+\
(?!\\n)$"
}
}
}
}
(Normative.) JSON Schema 2020-12; governs the fleet roster defined in Section 11. The document is Runtime-authored and therefore open (Section 13.1), and it pins shape only: whether a member exists says nothing about whether it may be addressed.¶
NOTE: '\' line wrapping per RFC 8792
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amap-spec/schemas/roster.schema.json",
"title": "roster",
"description": "runtime -> every agent: the fleet roster, the runt\
ime's most recent word about which instances it has admitted, wr\
itten once for the whole fleet to a location the deployment desi\
gnates and exposes read-only to each agent it points at the rost\
er. AMMP, section \"The Fleet Roster\". EXISTENCE IS NOT AUTHORI\
SATION: a member's presence says the instance exists, never tha\
t this agent may address it or that a submission to it will be a\
ccepted; authorisation is evaluated by the runtime at submit tim\
e and only the result reports what happened. NOT A SOURCE OF TH\
E READER'S IDENTITY: the reader's own address may appear, and at\
tribution stays the namespace a file arrived in. A runtime MUST \
NOT read this file back. The file MAY be absent; absent or stal\
e means unknown, never an empty fleet. Runtime-authored: envelop\
e is OPEN at every level — a consumer MUST ignore members it doe\
s not recognize; emitting an unrecognized member remains produce\
r non-conformance.",
"type": "object",
"required": [
"contract_version",
"router",
"written_at",
"members"
],
"properties": {
"contract_version": {
"const": "2"
},
"router": {
"type": "string",
"description": "the publishing runtime's own address, one bar\
e addr-spec. Never appears in members.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+(?\
!\\n)$"
},
"written_at": {
"type": "string",
"minLength": 1,
"description": "RFC 3339, the publishing runtime's clock. The \
only staleness signal a consumer gets; an honest marker, not a f\
reshness guarantee.",
"pattern": "^[^\\u0000-\\u001f\\u007f]*(?!\\n)$"
},
"interval_s": {
"type": "number",
"minimum": 0,
"description": "OPTIONAL: the runtime's nominal rewrite interv\
al in seconds, so a consumer can judge written_at against it. Om\
itted when the runtime does not know it."
},
"members": {
"type": "array",
"description": "the instances the runtime has admitted, sorte\
d by address. Always present; never written as empty when member\
ship is unknown (the previous file is left to age instead). The \
router is never a member.",
"items": {
"type": "object",
"required": [
"address",
"state"
],
"properties": {
"address": {
"type": "string",
"description": "the member's address, one bare addr-spec\
.",
"pattern": "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9\
.-]+(?!\\n)$"
},
"state": {
"type": "string",
"minLength": 1,
"description": "OPEN advisory vocabulary; \"admitted\" i\
s the only value defined at this version. A consumer MUST tolera\
te an unknown value as opaque.",
"pattern": "^[^\\u0000-\\u001f\\u007f]*(?!\\n)$"
}
}
}
}
}
}
(Normative.) The documents below are reproduced byte-verbatim from the conformance suite (Appendix J). They are a small selection covering the core artifact types and three rejections; the suite itself is the authority. One further example — a Binding Record — is shown inline at Section 9 instead of being repeated here.¶
Sidecar blobs are named by array index (req-<id>.attachments/<i>); no content_ref may appear on a request and the schema rejects one (Section 5.1).¶
NOTE: '\' line wrapping per RFC 8792
{
"contract_version": "2",
"req_id": "00000010",
"agent_id": "agent.research",
"ts": "2026-07-21T04:10:00Z",
"draft": {
"to": ["owner@example.com"],
"subject": "Report attached",
"body_text": "See the attached report and notes.",
"attachments": [
{ "filename": "report.pdf", "media_type": "application/pdf",
"size_bytes": 48213,
"sha256": "970e441b6c4ecd37abf2541e1276c680bdec8481cef9368ff\
1d010fd6b784b07" },
{ "filename": "notes.txt", "media_type": "text/plain",
"size_bytes": 128,
"sha256": "087d07499f2c097ac5396af7a0f1ef5e7a977fa91c631ebd1\
5eb61bb23ef20aa" }
]
}
}
content_ref's prefix equals the enclosing document's own notice_id and its trailing index equals the Descriptor's own array position (Section 7.2); a Connector that relays the Notice without ever dereferencing the reference is fully conformant.¶
NOTE: '\' line wrapping per RFC 8792
{
"contract_version": "2",
"notice_id": "1721457600000000003-4242",
"ts": "2026-07-21T04:06:00Z",
"kind": "deliver",
"message": {
"id": "45",
"from": "colleague@example.com",
"subject": "Clean attachment (deferred-release wire shape)",
"preview": "byte-access forward-valid example",
"mailbox": "inbox",
"attachments": [
{ "filename": "photo.jpg", "media_type": "image/jpeg",
"size_bytes": 204800,
"sha256": "04c22051bc3f76bc44b952d9fdd3e59214662e7b3fd8a84b8\
9de09352aeb2264",
"disposition": "clean",
"content_ref": "1721457600000000003-4242.attachments/0" }
]
}
}
The body spool for a delivered message is keyed on the Runtime-minted notice_id, never on the provider message.id (Section 7.3).¶
{
"contract_version": "2",
"notice_id": "1721457600000000000-4242",
"id": "42",
"date": "Mon, 20 Jul 2026 04:00:00 +0000",
"from": "Alex Doe <owner@example.com>",
"to": "agent@example.org",
"subject": "status?",
"body_text": "hey, how's the run going",
"attachments": [
{ "filename": "report.pdf", "media_type": "application/pdf",
"size_bytes": 48213, "disposition": "unscanned" }
]
}
attachments[i].index == i on a Result, contiguous and unique over 0..n-1; per-attachment entries are diagnostic detail under one governing top-level outcome and do not license a partial send (Section 6.1).¶
NOTE: '\' line wrapping per RFC 8792
{
"contract_version": "2",
"req_id": "00000010",
"outcome": "queued_for_human",
"detail": "no outbound screener wired; attachments fail-closed to \
human review",
"recipients": ["owner@example.com"],
"message_id": null,
"job_id": null,
"attachments": [
{ "index": 0, "outcome": "dlp_held", "detail": "no verdict sourc\
e" },
{ "index": 1, "outcome": "dlp_held", "detail": "no verdict sourc\
e" }
]
}
The drop rule of Section 7.1 is two independent checks: this document is schema-valid and is caught only by the content_ref post-check (Section 7.2, Section 13.3).¶
content_ref names a different notice's Sidecar directory and a non-matching index — it is neither <notice_id>.attachments/0 for its own array position 0, nor prefixed by this document's own notice_id. Schema validation alone reports zero errors; only the content_ref index/prefix post-check catches it.¶
The conformance gate's own errors for this document:¶
$.attachments[0].content_ref: 'OTHER-NOTICE.attachments/7' != 'n-index-mismatch.attachments/0'¶
{
"contract_version": "2",
"notice_id": "n-index-mismatch",
"ts": "2026-07-21T04:06:00Z",
"kind": "deliver",
"message": {
"id": "46",
"from": "colleague@example.com",
"subject": "wrong content_ref",
"preview": "descriptor points at another notice's sidecar",
"mailbox": "inbox",
"attachments": [
{ "filename": "photo.jpg", "media_type": "image/jpeg",
"size_bytes": 204800,
"disposition": "clean",
"content_ref": "OTHER-NOTICE.attachments/7" }
]
}
}
The Runtime-authored envelope is OPEN: a consumer MUST ignore a member it does not recognize, at every level (Section 13.1).¶
NOTE: '\' line wrapping per RFC 8792
{ "contract_version": "2", "notice_id": "x", "ts": "t", "kind": "del\
iver", "x_injected": "surprise",
"message": { "id": "1", "from": "a@b", "subject": "s", "preview": \
"p", "mailbox": "inbox",
"x_message_injected": "also surprise" } }
The Agent-authored envelope stays CLOSED at every level: an unrecognized member is a designed, legible rejected refusal, never a silent drop (Section 13.1).¶
additionalProperties: false at the top level of the submit-request schema rejects any member this document does not define.¶
The conformance gate's own errors for this document:¶
$: additional property 'x_injected' not allowed¶
NOTE: '\' line wrapping per RFC 8792
{ "contract_version": "2", "req_id": "00000021", "x_injected": "surp\
rise",
"draft": { "to": ["a@b"], "subject": "s", "body_text": "b" } }
Origin class comes from the tree a notice arrived in, never from a self-declared field: a kind: "deliver" document found under peer/notices/ fails the peer-notice schema, the profile's cross-lock (Section 12.2).¶
kind is const "peer" in the peer-notice schema; a document carrying "deliver" fails that const check.¶
The conformance gate's own errors for this document:¶
$.kind: 'deliver' != const 'peer'¶
NOTE: '\' line wrapping per RFC 8792
{ "contract_version": "2", "notice_id": "9f1c2b7e4d3a4f60b8e2c1d5a7f\
39e04", "ts": "t", "kind": "deliver",
"message": { "id": "9f1c2b7e4d3a4f60b8e2c1d5a7f39e04", "from": "pl\
anner@fleet.example", "subject": "s", "preview": "p", "mailbox"\
: "peer" } }
(Normative for the classes of Section 13.3; the honesty statement below is part of the specification.)¶
The suite consists of golden artifacts under fixtures/valid/ and
fixtures/invalid/, plus a validator, fixtures/validate.py.¶
The suite contains 121 fixtures — 42 valid and 79 invalid.¶
| fixture | schema |
|---|---|
valid/directory-both-lanes.json
|
directory.schema.json
|
valid/directory-delegation-only.json
|
directory.schema.json
|
valid/directory-minimal.json
|
directory.schema.json
|
valid/directory-unknown-member.json
|
directory.schema.json
|
valid/identity-basic.json
|
binding-record.schema.json
|
valid/identity-labeled.json
|
binding-record.schema.json
|
valid/message-clean.json
|
inbound-message.schema.json
|
valid/message-unknown-member.json
|
inbound-message.schema.json
|
valid/notice-attachment-clean-ref.json
|
deliver-notice.schema.json
|
valid/notice-attachments-descriptors.json
|
deliver-notice.schema.json
|
valid/notice-enriched.json
|
deliver-notice.schema.json
|
valid/notice-minimal.json
|
deliver-notice.schema.json
|
valid/notice-provenance-unrecognized-value.json
|
deliver-notice.schema.json
|
valid/notice-unknown-member.json
|
deliver-notice.schema.json
|
valid/peer-attachment-clean-ref.json
|
peer-notice.schema.json
|
valid/peer-exposure-false.json
|
peer-notice.schema.json
|
valid/peer-exposure-true.json
|
peer-notice.schema.json
|
valid/peer-minimal.json
|
peer-notice.schema.json
|
valid/peer-result.json
|
peer-notice.schema.json
|
valid/peer-unknown-member.json
|
peer-notice.schema.json
|
valid/request-attachment-empty-file.json
|
submit-request.schema.json
|
valid/request-attachments-empty.json
|
submit-request.schema.json
|
valid/request-attachments.json
|
submit-request.schema.json
|
valid/request-minimal.json
|
submit-request.schema.json
|
valid/request-reply.json
|
submit-request.schema.json
|
valid/result-accepted.json
|
result.schema.json
|
valid/result-attachments-accepted.json
|
result.schema.json
|
valid/result-attachments-held.json
|
result.schema.json
|
valid/result-attachments-rejected-malformed.json
|
result.schema.json
|
valid/result-queued.json
|
result.schema.json
|
valid/result-unknown-member.json
|
result.schema.json
|
valid/roster-basic.json
|
roster.schema.json
|
valid/roster-empty.json
|
roster.schema.json
|
valid/roster-unknown-member.json
|
roster.schema.json
|
valid/control-demoted-body-digest.json
|
peer-control.schema.json
|
valid/control-dropped-duplicate.json
|
peer-control.schema.json
|
valid/control-refused-no-edge.json
|
peer-control.schema.json
|
valid/control-refused-policy.json
|
peer-control.schema.json
|
valid/statement-exposure-true.json
|
peer-statement.schema.json
|
valid/statement-minimal.json
|
peer-statement.schema.json
|
valid/statement-result-attachment.json
|
peer-statement.schema.json
|
valid/statement-sender-since.json
|
peer-statement.schema.json
|
invalid/directory-display-name-address.json
|
directory.schema.json
|
invalid/directory-missing-mail-to.json
|
directory.schema.json
|
invalid/directory-version-1.json
|
directory.schema.json
|
invalid/identity-extra-field.json
|
binding-record.schema.json
|
invalid/identity-missing-address.json
|
binding-record.schema.json
|
invalid/message-missing-body.json
|
inbound-message.schema.json
|
invalid/notice-attachment-bad-disposition.json
|
deliver-notice.schema.json
|
invalid/notice-attachment-content-ref-trailing-newline.json
|
deliver-notice.schema.json
|
invalid/notice-attachment-missing-disposition.json
|
deliver-notice.schema.json
|
invalid/notice-attachment-ref-index-mismatch.json
|
deliver-notice.schema.json
|
invalid/notice-attachment-ref-not-clean.json
|
deliver-notice.schema.json
|
invalid/notice-attachment-sha-not-clean.json
|
deliver-notice.schema.json
|
invalid/notice-attachment-traversal-ref.json
|
deliver-notice.schema.json
|
invalid/notice-kind-peer.json
|
deliver-notice.schema.json
|
invalid/notice-missing-preview.json
|
deliver-notice.schema.json
|
invalid/notice-notice-id-trailing-newline.json
|
deliver-notice.schema.json
|
invalid/notice-traversal-noticeid.json
|
deliver-notice.schema.json
|
invalid/notice-version-1.json
|
deliver-notice.schema.json
|
invalid/notice-wrong-kind.json
|
deliver-notice.schema.json
|
invalid/peer-attachment-ref-index-mismatch.json
|
peer-notice.schema.json
|
invalid/peer-exposure-missing-asserted-by.json
|
peer-notice.schema.json
|
invalid/peer-exposure-true-missing-last-ts.json
|
peer-notice.schema.json
|
invalid/peer-from-crlf.json
|
peer-notice.schema.json
|
invalid/peer-from-display-name.json
|
peer-notice.schema.json
|
invalid/peer-in-reply-to-encoded.json
|
peer-notice.schema.json
|
invalid/peer-kind-deliver.json
|
peer-notice.schema.json
|
invalid/peer-mailbox-inbox.json
|
peer-notice.schema.json
|
invalid/peer-message-id-encoded.json
|
peer-notice.schema.json
|
invalid/peer-notice-id-trailing-newline.json
|
peer-notice.schema.json
|
invalid/request-attachment-bad-sha.json
|
submit-request.schema.json
|
invalid/request-attachment-content-ref.json
|
submit-request.schema.json
|
invalid/request-attachment-filename-crlf.json
|
submit-request.schema.json
|
invalid/request-attachment-media-type-trailing-newline.json
|
submit-request.schema.json
|
invalid/request-attachment-missing-sha.json
|
submit-request.schema.json
|
invalid/request-attachment-negative-size.json
|
submit-request.schema.json
|
invalid/request-empty-to.json
|
submit-request.schema.json
|
invalid/request-extra-field.json
|
submit-request.schema.json
|
invalid/request-recipient-display-name.json
|
submit-request.schema.json
|
invalid/request-recipient-trailing-newline.json
|
submit-request.schema.json
|
invalid/request-reply-id-crlf.json
|
submit-request.schema.json
|
invalid/request-reqid-trailing-newline.json
|
submit-request.schema.json
|
invalid/request-subject-crlf.json
|
submit-request.schema.json
|
invalid/request-subject-trailing-newline.json
|
submit-request.schema.json
|
invalid/request-traversal-reqid.json
|
submit-request.schema.json
|
invalid/request-version-absent.json
|
submit-request.schema.json
|
invalid/result-attachment-bad-outcome.json
|
result.schema.json
|
invalid/result-attachments-dup-index.json
|
result.schema.json
|
invalid/result-attachments-gap-index.json
|
result.schema.json
|
invalid/result-attestation-missing-form.json
|
result.schema.json
|
invalid/result-bad-attestation-form.json
|
result.schema.json
|
invalid/result-bad-outcome.json
|
result.schema.json
|
invalid/roster-display-name-address.json
|
roster.schema.json
|
invalid/roster-member-missing-state.json
|
roster.schema.json
|
invalid/roster-missing-members.json
|
roster.schema.json
|
invalid/roster-version-1.json
|
roster.schema.json
|
invalid/control-free-text.json
|
peer-control.schema.json
|
invalid/control-kind-peer.json
|
peer-control.schema.json
|
invalid/control-outcome-cause-mismatch.json
|
peer-control.schema.json
|
invalid/control-policy-code-without-policy.json
|
peer-control.schema.json
|
invalid/control-policy-without-code.json
|
peer-control.schema.json
|
invalid/statement-attachment-unknown-member.json
|
peer-statement.schema.json
|
invalid/statement-digest-uppercase.json
|
peer-statement.schema.json
|
invalid/statement-exposure-date-free-text.json
|
peer-statement.schema.json
|
invalid/statement-exposure-unknown-member.json
|
peer-statement.schema.json
|
invalid/statement-from-display-name.json
|
peer-statement.schema.json
|
invalid/statement-id-dotted.json
|
peer-statement.schema.json
|
invalid/statement-id-leading-dash.json
|
peer-statement.schema.json
|
invalid/statement-in-reply-to-leading-dash.json
|
peer-statement.schema.json
|
invalid/statement-id-encoded.json
|
peer-statement.schema.json
|
invalid/statement-key-id-empty.json
|
peer-statement.schema.json
|
invalid/statement-missing-attachments.json
|
peer-statement.schema.json
|
invalid/statement-sender-since-date-only.json
|
peer-statement.schema.json
|
invalid/statement-subject-crlf.json
|
peer-statement.schema.json
|
invalid/statement-to-array.json
|
peer-statement.schema.json
|
invalid/statement-ts-fractional.json
|
peer-statement.schema.json
|
invalid/statement-ts-offset.json
|
peer-statement.schema.json
|
invalid/statement-unknown-member.json
|
peer-statement.schema.json
|
invalid/statement-v-boolean.json
|
peer-statement.schema.json
|
invalid/statement-v2.json
|
peer-statement.schema.json
|
Running it. The validator is stdlib-only and takes no arguments:¶
python3 fixtures/validate.py¶
It exits 0 if and only if every fixture under valid/ passes and every
fixture under invalid/ fails. It requires no counterpart, no network, and
no dependency: the fixtures are the other side.¶
Signed cases for the peer-origin profile. The validator then
runs fixtures/peer-signed/check_cases.py, which checks three further sets,
all of which a Runtime + peer-origin implementation that verifies peer traffic
from other hosts MUST pass (Section 13.3):¶
rfc8032-vectors.json: the test vectors of
Section 7.1 of [RFC8032] (TEST 1 to 3), and negative vectors for the
verification policy of Section 12.6.7, each marked for rejection
either at verification or at configuration, which are two separate gates;¶
mime/: 10 raw messages, and the candidate the selection rule of
Section 12.6.2 makes of each, reduced by a byte-exact
splitter;¶
cases/: 114 sequences of candidates and events (a crash at each
point of Section 12.6.5, recovery, loss of the store with and
without H, an operator bound, clock and window changes, and edge and key
changes), each step with the outcome, cause, published members and Control
Notice that the receiver configured in receiver.json or
receiver-host-a.json MUST reach. Single-step cases break exactly one
rule; multi- cases pin the precedence of
Section 12.6.3; seq- cases pin state across steps;
config- cases are configurations a receiver MUST refuse to load. The
receiver files set retention to its minimum, P, and the cases that probe the
retention boundary assume it; a receiver that retains records longer, as
Section 12.6.5 permits, maps those steps by its own
retention.¶
check_cases.py models only the state that
Section 12.6.5 makes normative. It is a self-check of the
fixtures, not a Runtime, and its Ed25519 implementation, which is slow and not
constant-time, exists for the gate alone.¶
How a fixture is mapped to a schema. By filename prefix — request- to
the submit-request schema, notice- to deliver-notice, result- to
result, message- to inbound-message, identity- to binding-record, and
peer- to peer-notice, statement- to peer-statement,
control- to peer-control (Appendix F), directory- to
peer-directory (Appendix G) and roster- to roster
(Appendix H).¶
What the validator checks. Schema validation in the JSON Schema
2020-12 dialect [JSON-SCHEMA-2020-12], over the subset of keywords these
schemas use (type, const, enum, minLength, pattern, minItems,
minimum, items, required, properties, additionalProperties,
allOf, and a single if/then pair) — plus two named post-checks,
neither expressible in standard JSON Schema:¶
the Result-side binding attachments[i].index == i, contiguous and
unique over 0 through n-1 (Section 6.1);¶
the content_ref index and prefix binding on Notice, Inbound Message,
and peer-notice documents: every Descriptor carrying content_ref must
equal <notice_id>.attachments/<i> for its own array position i,
binding both the trailing index and the leading prefix (Section 7.2).¶
A Connector conformance claim requires running both the schema
validation and the second post-check, because exactly one invalid
notice-* fixture — and its peer-* counterpart — is schema-valid on its
own and is caught only by that post-check (Appendix I, Section 13.3).¶
A note on pattern anchoring, and a portability cost. Every schema
pattern in this suite that ends in $ is written as (?!\n)$. The reason
is that a bare $ matches before a single trailing newline in several
widely used regex engines — Python's re, Java, and .NET among them —
unlike the ECMA-262 dialect JSON Schema specifies. Since every one of these
patterns exists to forbid exactly the control characters a permissive $
would let through at the end of a string, the lookahead closes that gap in
the schemas themselves rather than in any one validator. Seven fixtures pin
the trailing-newline case specifically, distinct from the CRLF fixtures,
which embed \r\n and were already rejected even under a permissive
engine.¶
The cost is disclosed rather than discovered: lookahead-less engines
cannot compile these patterns at all. RE2 (Go's regexp), Rust's
regex crate, and POSIX ERE reject (?! outright, so the eight schemas
carrying the guard (every schema except result and binding-record) fail to
load rather than validating loosely. Those
engines already treat $ as strict end-of-string and never had the gap,
so an implementer on one of them should strip the lookahead — not add a
check outside the pattern, which would be both unnecessary and not what
breaks. The failure is loud (a compile error, never silent acceptance).¶
What a green run proves. That every document shape this protocol defines is accepted or rejected as specified, by an implementation's own parser, with no counterpart present.¶
What a green run does NOT prove. The following are filesystem and policy invariants that no document validator can observe. They are obligations of the conformance classes regardless (Section 13.3), and are checked operationally:¶
Sidecar file existence — an extra file, a missing file, or a directory present when the Descriptor array is empty or absent.¶
size_bytes and sha256 actually matching the bytes on disk.¶
filename display-sanitization, and the "never a path" invariant.¶
The binding between an ordinal and a directory entry on disk. The schema
checks only the Descriptor array's own index member, not that
directory entry N exists.¶
Inbound Sidecar directories keyed on the Runtime-minted notice
identifier rather than the provider message.id.¶
The location of Runtime-private Staging, outside the mounted Volume.¶
Write ordering and commit-sentinel atomicity — Sidecars before
req-<id>.json; .tmp plus rename.¶
Caps and allowlists — type allowlists, size caps, Volume quotas.¶
The absence of a spooled body for a quarantined or blocked message (enforcement by absence, Section 7.3).¶
A Connector's tolerance of a read-only inbound/ tree. The prescribed
check is operational, not a fixture: run the Connector against an
inbound tree it cannot write and confirm that it starts, relays every
pending Notice, resolves bodies, and submits outbound requests, without
requiring any inbound write to succeed (Section 13.3).¶
Ingest by copy — validation, policy evaluation, and composition running over a Runtime-private snapshot taken before any of those steps, rather than re-reading an Agent-writable path (Section 4.1).¶
Write-side path discipline, all three conjunctive legs: full
parent-chain resolution through pinned directory descriptors from the
Namespace root; each directory component opened O_NOFOLLOW|O_DIRECTORY
at the moment it is added to that chain; and the final component created
O_EXCL and never followed, failing closed on EEXIST and ELOOP. Any
one leg alone is a known-insufficient partial mitigation (Section 4.1).¶
Read-side open discipline — per-component no-symlink-follow opens,
non-blocking opens, fstat-based regular-file verification through the
open descriptor, and bounded reads and caps on every Agent-influenced
path (Section 4.1).¶
Single writer and single drainer per Namespace — an operational and deployment invariant, such as an OS-level lock held for the process lifetime (Section 4.1).¶
Compose-time header-boundary stripping: even where the schemas' control-character patterns are bypassed upstream, the Runtime rejects or strips CR, LF, and NUL before composing the outbound message (Section 5.1).¶
The peer-origin profile's operational obligations (Section 12),
none of them visible to a document validator: the write-authority partition —
no component that admits messages from a transport not restricted to
authorised peer senders holds write authority over peer/, and the
prescribed check is a write attempt from inside the admitting component
that must fail; intake isolation for shared-transport peer candidates, in
a location that is neither inbound/ nor peer/ and unreachable from
any Agent Namespace; verification placement in a component holding peer
write authority that hashes body and attachment bytes itself;
sender_exposure never sender-settable; a Peer Notice carrying
in_reply_to only when the placing Runtime resolved it from its own
record, an unresolvable key being refused rather than stripped; and
at-most-once action by the Connector, with an ambiguous target held
rather than fanned out. Also invisible to the gate: the durability of the
replay store, of H, of each record's disposition and of pending Control Notices
across restarts; the closing of admission when H is lost; and the protection of
the signing key (Section 12.6).¶
Beyond these, the behavioral obligations of Sections 7 and
8 — the untrusted-content posture, the treatment of task_id as
correlation rather than authorization, and the requirement that verified
sender identity never be taken from a mail message.from — sit outside the
fixture gate entirely. They constrain what an implementation does with a
valid document, which no document-level gate can see.¶
This appendix is non-normative and is not in force at this document version. It records the reasoning behind a gap deliberately left open, not an addition to the protocol. Nothing below is a directory, a member, or an artifact that a conforming implementation can rely on, and no requirement keyword appears anywhere in it.¶
The gap. Making inbound consumption-tracking Connector-private (Section 4.1) closes the writability conflict but leaves a knowledge and capability split unbridged: only the Connector knows that a Notice has been relayed to its Agent, while on a read-only inbound tree only the Runtime can delete anything. Neither side alone can turn "consumed" into "reclaimed."¶
A sketch, not a specification. One shape that would bridge it is a
Connector-written, zero-byte marker per acknowledged Notice, written into
outbound/, where the Connector already has write access in every
posture:¶
outbound/acks/notice-<id> # sketch only - not part of the protocol
A Runtime could then delete the acknowledged Notice and its spooled message, and finally the marker itself. This is presented purely as a sketch: no directory and no wire member is introduced by this appendix.¶
Why a Connector-forgeable signal would be acceptable here. A garbage-collection signal is a resource decision rather than a security decision. An Agent that falsely claims consumption loses only its own unread mail; one that never claims consumption only bloats its own inbox. Neither crosses a trust boundary that matters, and neither touches any record of the exchange the Runtime keeps on its own side. That is what distinguishes this case from every other "the Agent writes it, so it cannot be trusted" case in this document — but it is a distinction a future version would need to state explicitly rather than assume.¶
Why a high-water mark cannot substitute. Section 8 requires task_id
to be unguessable and never sequential, and nothing in this document
orders notice identifiers, so no single "processed through N" cursor is
expressible. Any consumption signal has to be a set of identifiers, or
time-ordered — and time-ordering would require the consumer to process
strictly in delivery order, which nothing today requires.¶
The lifecycle this would enable, and its boundaries. A reliable "is this one done?" answer is the watermark a real retention lifecycle promotes against: cycling a working set, rolling handled traffic into period-compacted archives, then expiring or tiering to cold storage. Three boundaries would need to hold if this is ever specified.¶
The seam is not the record. A Runtime that needs an exchange of record already keeps one outside this protocol, tamper-evident. Retention here is about an Agent's access to its own history, not about the durability of evidence; conflating the two produces an archive that satisfies neither.¶
An Agent-writable archive is never evidence. A compacted archive
living under outbound/, which is Agent-writable in every posture, can
be rewritten by the Agent it belongs to. Anything meant to be relied on
has to be Runtime-written or kept outside this protocol entirely.¶
It must not grow the read surface. The read available today is a point lookup from a notice identifier to a body. "Search my archive" is a materially larger capability — decompression, indexing, query — for a deliberately dumb, credential-free relay to take on. If archives are ever exposed to an Agent, that likely belongs behind a Runtime-served surface rather than the file-drop seam.¶
Why this is deferred rather than specified now. A capability starts with a change to this document once a second implementation independently wants it, with members and fixtures proposed together. Today exactly one reference Connector carries a private workaround for the underlying problem — a writability-probing fallback with three modes: move, ledger, and in-memory. There is no second data point yet to generalize from, and specifying now would freeze one side's convention as the protocol's answer.¶
One honesty note for a future implementer. The sketch's own garbage-collection step is not atomic: if a Runtime crashes between deleting the Notice artifacts and deleting the acknowledgment marker, the marker is orphaned, pointing at a Notice that no longer exists. Any real design needs idempotent collection, in which a missing target is not an error — recorded here so that it is not rediscovered from scratch.¶
This section is to be removed before publishing as an RFC.¶
(Informative.)¶
AMMP was specified and implemented before it was brought to the IETF. Its contract was versioned on its own scale, from 1.0.0 to 3.1.0, separately from the wire version that documents carry. This appendix records that history, newest first, because implementations were built against each version, and because the reasons for several rules in this document are clearest as the story of what went wrong without them. An entry that turned out to be wrong is left standing, with a correction attached, rather than quietly rewritten.¶
One document. The peer-origin profile's prose, previously a
companion document in the repository, is now Section 12. The
contract's own version numbers no longer appear in the protocol sections:
revisions of this document are draft revisions, and the wire version is
contract_version (Section 13.1), which stays
"2". The reasoning that explained earlier version changes moved into
this appendix (Appendix L.2).¶
The protocol is AMMP. It was AMAP, the Agent Mailbox Access Protocol, and is renamed the Agent Mediated Messaging Protocol before its first submission: "mailbox access" named only its first channel, and the contract is a mediation seam for an Agent's correspondence, mail and peer traffic alike. The abstract and the definitions of Agent and Runtime describe it that way, as a store-and-forward messaging runtime; email remains the only transport this document defines. The acronym AMAP was also already in use by an expired 2003 individual draft. Nothing on the wire changes: no document carries the protocol's name, and the header fields of Section 15.1 keep their names.¶
Editorial review for submission. Terminology was alphabetized and Drop and Fail Closed became defined terms; requirements previously phrased as "never" are, where they are requirements, stated with [RFC2119] keywords; references to earlier revisions were removed from the protocol sections and the security considerations; the open questions the authors know of are listed in Section 1.6; and IANA considerations were added for the two header fields the profile defines.¶
The signed statement between hosts, after reports from
the first implementations of revision s5-r4, which now stands as the profile's
text, without a revision label (an implementation names the draft revision it
implements): the id grammar of the Peer Statement and Control Notice
schemas is narrowed to ^[A-Za-z0-9_][A-Za-z0-9_-]*$, the
notice_id character set without "."
(Section 12.6.1; see the correction to the 3.1.0 entry below);
a point is decoded canonically before the small-order check
(Section 12.6.7), and the gate's reference Ed25519 decoder
was corrected to match, with two "negative zero" vectors added; the
12288-octet limit is stated to apply to a field body as transmitted
(Section 12.6.2); Fmax after the loss of H is taken
from the retained history of F if provable, and is 900 otherwise
(Section 12.6.5); and how a Runtime divides its intake is
stated to be an implementation choice (Section 12.6.10).
Fixtures: two invalid statement fixtures (121 in all: 42 valid, 79
invalid); signed cases for a leading "-" and for the loss of the F
history (114); and a MIME case for the size limit (10).¶
The move from wire major "1" to "2". When
attachment support was added, every top-level schema and every nested object
except verdict and attestation.claims was
additionalProperties: false. Under that regime, adding a member was
not a safe additive change: a strict major-1 validator rejected the
whole document, not just the new member. Inbound, that was
silent total message loss — an attachment-bearing email
vanished instead of degrading. Outbound, it was an accidental schema error
rather than a designed refusal. And there was no signal to gate on while
contract_version stayed "1". That is why attachments
required a major change: a major-1 consumer receiving a "2" document
failed closed by version, a legible and designed refusal, and both
sides upgraded deliberately.¶
Two version axes. From contract 3.0.0 the contract's own
version and the wire major were allowed to diverge. The wire major tracks
whole-document acceptance compatibility; the contract version tracked the
fuller set of obligations, including narrower, disclosed producer-side
tightenings. Contract 3.0.0 was a major contract change — it moved fixtures
between the valid and invalid sets, and added Runtime obligations that a
strictly compliant earlier implementation did not meet — while the wire
major stayed "2". Neither of its two producer-side narrowings was the
failure a wire major exists to prevent: the Submit Request tightening yields
a per-request rejected Result, and the notice_id
character-set tightening yields a per-Notice drop under check 1 of
Section 7.1, a real cost, disclosed, but narrow and legible.
Changing the wire major to "3" would instead have forced every
major-2 counterpart on both sides to refuse all traffic, including all the
traffic the new tolerance rule protects. Contract 3.1.0 was an additive minor
change over 3.0.0, and the wire major stayed "2".¶
Corrections. Two claims that shipped in earlier revisions of the contract's normative text were false, and were named rather than quietly replaced, because an implementer who read them may have built against them.¶
The write-rule "strengthening" that was a regression. An
earlier revision described the exclusive-create and no-symlink-follow
requirement on the final path component as having been "strengthened to" the
pinned directory-descriptor walk — that is, replaced by it — and deleted the
earlier clause outright. That was a weakening: a parent chain
pinned by descriptor is silent about the final .tmp filename itself,
so dropping exclusive-create there let an Agent that pre-plants a symlink at a
predictable .tmp name have the Runtime write through it.
Section 4.1 states the rule conjunctively, with all three legs
required together.¶
The drop rule described as a closure over schema validation.
An earlier revision described the inbound drop rule as a pure closure over
schema validation, and claimed that formulation was exact against the fixture
set. Both statements were false: the content_ref index and prefix
binding is not expressible in the schema dialect used here, and a fixture
exists that is schema-valid on its own and must nonetheless be dropped.
Section 7.1 states two independent checks, and so does the
Connector conformance class.¶
A third correction concerns the change history itself: the contract
version that introduced message.provenance was labelled an additive
minor change, which was unsound against the strictly validating consumers of
the time. The 2.1.0 entry below records what actually happened.¶
Lessons from implementation. Several rules in this document
exist because an early implementation got them wrong: it trusted a
req_id member inside a request body to name the path its Result was
written to; it used the provider's message identifier as a path component; it
minted guessable, sequential task_id values; and its schema coverage
of control characters stopped at subject and filename, missing
the two members composed directly into headers. Each was a confirmed exploit
path, and each is now a requirement (Section 4.1,
Section 5.1, Section 8).¶
The peer-origin profile's presentation rule. A prior revision of the profile said only that a Connector "MAY present peer bodies under the same untrusted-content framing it uses for mail". That was a permission with no floor: it allowed the mail framing without saying what any framing had to preserve. A Connector following it chose the cautious option, which on this lane is the wrong one, in the specific direction of telling an Agent not to act on work it was authorized to do. Section 12.8 states what a presentation must and must not convey.¶
An additive minor bump; the wire major stays "2".
The entries below are as recorded at the time: the companion document they
cite is now Section 12, and the items marked proposed were
adopted in draft-rapp-ammp-00 (Appendix L.1).¶
The signed cross-host peer statement and control notice
(2026-10-09, proposed, not yet reviewed; companion-document section 5 revision s5-r4).
Two new schemas (Appendix F): the statement a sending Runtime signs with
Ed25519 and carries in one AMAP-Peer-Statement header field, and the control
notice a receiving Runtime returns about a statement it did not place, both CLOSED at
every level; statement-* and control-* fixtures; and, in
fixtures/peer-signed/, RFC 8032 vectors with negative cases, MIME selection
cases, and signed sequences of candidates and events with the outcome a receiver MUST
reach at each step. The rules are the companion document's (spec/peer-origin.md,
section 5). Additive: nothing existing changes shape, and a deployment without
cross-host peer traffic is unaffected. The 30 new fixtures (8 valid, 22 invalid)
bring version 3.1.0 to 119 (42 valid, 77 invalid).¶
Correction (draft-rapp-ammp-00). The statement schema
accepted an id or in_reply_to beginning with "-",
which the notice_id character set forbids, so a receiver could verify a
statement it could not then publish; one signed case minted such an id. The
grammar is narrowed (Appendix L.1), and the case was minted
again.¶
The fleet roster (2026-09-24). A new, optional artifact (Section 11, schema in Appendix H): one Runtime-written file naming the instances the Runtime has admitted, so that an Agent on a deployment whose addresses carry an underivable suffix can learn which peers exist. It answers existence, never authorisation, and states that separately from the Peer Directory. Its readability obligation is split between the Runtime, which publishes only into a designated location and never creates it, and the deployment, which exposes that location. Additive: a new schema and seven fixtures, and nothing existing changes shape.¶
The protocol is AMAP; the schema $id authority is amap-spec
(2026-09-15). The protocol formerly called AMP ("Agent Mailbox Protocol")
is renamed AMAP ("Agent Mailbox Access Protocol") because AMP collides
with the IETF DTN working group's adopted draft-ietf-dtn-amp; the
specification repository moves from agent-mailbox-protocol to
amap-spec with it. The six schema $id values move from
https://agent-mailbox-protocol/schemas/… to https://amap-spec/schemas/….
Nothing on the wire changes shape: no document carries the protocol's name
as a field, and $id is an identifier the gate never dereferences.
Implementations that mirror the Runtime's address spell its local part
amap.router from this version. This draft's filename is a separate
decision and is unchanged here. This version is marked DRAFT: its profile
prose is a companion document (spec/peer-origin.md, itself DRAFT), and
its schema and fixtures may still change before it is settled.¶
Outbound extension space. outbound/ext/<name>/ is defined as a
Connector-owned side channel a Runtime MAY read by out-of-band agreement,
with no shape specified and nothing security-relevant permitted to depend
on it (Section 4.1). <name> is pinned as a stable Connector id —
chosen once, unchanged by any rename of the Connector's repository,
package, or deployment, and opaque to the Runtime that reads it; the
wording it replaced ("a Connector or deployment name") permitted a
repository name to leak into a wire-visible path. The reference Claude
Code Connector chose claude-code as its id; ids are chosen by each
Connector, and this document neither lists nor registers them. One normative clarification sits beside it: an outcome
written there is a claim by the receiving side, never proof of delivery
or of non-delivery, and the Runtime's own delivery record is
authoritative over it (Section 14.15). There is deliberately no inbound
extension space.¶
Peer-notice schema. The peer-origin profile's artifact is pinned by
peer-notice.schema.json (Appendix F): the deliver-notice layout with
kind and mailbox fixed to peer, message.from a bare addr-spec,
message identifiers in the notice-identifier character set, and the
optional Runtime-asserted sender_exposure stamp with its required
members. The profile's prose is the companion document
spec/peer-origin.md (v3.1.0 DRAFT).¶
The Peer Directory. A Runtime may publish a per-instance
projection of the authorisation graph, directory.json, at the Agent's
Namespace root (Section 10, schema in Appendix G), so that
a Connector can learn which peers it may address without the host rendering a
snapshot onto an Agent-writable mount that goes stale whenever the fleet
changes. It is advisory and never authoritative, and a Runtime never reads it
back as authorisation, which is what stops it decaying into an allowlist the
Agent can see. The Namespace root gains a Runtime-owned posture for
Runtime-written files: the file sits beside the lanes rather than inside one,
because it spans both. Additive: a new optional artifact, a new schema and
seven fixtures (4 valid, 3 invalid); no existing schema or fixture changes,
and the wire contract_version stays "2".¶
Restored 2026-10-05. This entry was in the versioning record this document was reconciled against and was lost in the reconciliation, along with its fixtures from the count below.¶
Clarifications from implementation (proposed, 2026-10-07). Questions
from Runtime implementers reconciling against this document, each answered
from the existing text and then stated: a Runtime knows the <agent-id>
of every Namespace it serves and rejects a request from a Namespace it cannot
name (Section 4.1); outbound/ext/<name>/'s id is a lowercase
single path segment (Section 4.1); a Runtime may be built from several
trusted components, and its obligations bind the composite (Section 3.5,
informative); the Deletion rights table gains rows for outbound/ext/<name>/
and directory.json (Section 4.2); message.id is the mail store's
identifier, not the Message-ID (Section 7); and a Runtime does not hand mail
for special-use or private-use domain names to a mail system outside the
deployment (Section 14.16). The companion peer-origin profile clarifies
what a routable peer domain is. No schema or fixture changes.¶
Fixtures: 75 total (27 valid, 48 invalid), up from 58. 16 peer-*
fixtures were added (6 valid, 10 invalid), covering a minimal notice, a
reply, the exposure stamp asserted both ways, a clean attachment
reference, an unknown-member case, and rejections for a deliver kind
under the peer tree, a peer kind under the inbound tree, a display-name
or CRLF from, encoded message identifiers, a missing asserted_by, a
missing last-delivery timestamp when exposure is asserted true, an inbox
mailbox, a content_ref index mismatch, and a trailing-newline
identifier. The validator maps the peer- prefix to the new schema and
applies the content_ref post-check to it. Three existing fixtures had
example strings normalised to example.* identities (one free-text body,
two addresses in injection examples); each stays valid or invalid for the
same reason as before, and no schema changed.¶
Correction (2026-10-05). The count above omits the seven
directory-* fixtures (4 valid, 3 invalid) added with the Peer Directory.
With them and the fleet roster's seven, version 3.1.0 has 89 fixtures (34
valid, 55 invalid).¶
Correction (draft-rapp-ammp-00). The breakdown above does not sum: 58 + 16 is 74, and 37 + 10 is 47. The record does not identify the seventeenth addition. The totals from 89 onward are the ones verified against the suite as it ships (Appendix J).¶
A major document bump; the wire major stays "2". Appendix L.2 states
why the two numbers diverge here, and why bumping the wire would have
caused the exact harm this version exists to repair.¶
Unknown-member tolerance, direction-aware. The three Runtime-authored
artifacts — deliver-notice, inbound-message, and result — became open
at every level: additionalProperties: false was removed from the top
level, from message, from Descriptor items, and from attestation, and
a consumer must tolerate an unrecognized member. Emitting one remains
producer non-conformance. The Agent-authored submit-request envelope
stays closed at every level. Consequence: adding an optional member
to a Runtime-authored artifact is now genuinely additive; adding any
member to the submit-request is a major change.¶
...and for values. The enum constraint was removed from
message.sender_standing and message.provenance, the two explicitly
advisory vocabularies, and a consumer must tolerate an unrecognized value
in either. kind, outcome (both levels), and disposition remain
closed, enum-locked vocabularies.¶
Ingest by copy. A Runtime must copy an Agent-authored request and its Sidecars into private staging before validating, must act only on that copy, and must not re-read an Agent-writable path after a decision depending on its content (Section 4.1).¶
Address syntax and header-injection discipline. A new obligation in
Section 5.1 plus schema patterns on draft.to/cc, draft.subject,
attachment filename and media_type, draft.reply_to_message_id,
in_reply_to, agent_id, and ts — including the two members composed
directly into headers, which earlier schema coverage missed.¶
Path discipline, write- and read-side both. The write-side rule
became conjunctive (Section 4.1), and a new read-side rule applies
the equivalent discipline to every Agent-influenceable read. A
notice_id character-set pattern was added to the deliver-notice and
inbound-message schemas so that every legal notice_id is also a legal
content_ref prefix. Disclosed cost: notice_id was previously
unconstrained, so a Runtime minting identifiers outside the new character
set — an ISO-8601 timestamp, standard base64, a URN, or anything else
carrying punctuation outside it — now emits Notices and Inbound Messages
that fail schema validation on a pattern violation on a required
member, and a conforming Connector drops them. Migration: such a
Runtime must re-mint identifiers under the new character set (unpadded
base64url or lowercase hex, for instance) before any Connector it talks
to upgrades, because the failure is otherwise silent on both sides.¶
Single writer, single drainer. Section 4.1 now names the
concurrency assumption that every atomicity, commit-sentinel, and
req_id rule already depended on.¶
message.from named untrusted. The untrusted-content sentence now
covers explicitly the member most likely to be rendered to a human or
used for a trust judgement (Section 7.1).¶
Document authority. The contract, its schemas, and its fixtures were made normative; the rendering then generated from them was non-authoritative where they disagreed. (This document is now the prose of record.) The conformance-class text was moved into the contract itself so that the demotion deletes nothing, and a stray claim of a second, message-specific Sidecar directory was corrected to match the layout tree.¶
Corrections shipped with this version, rather than a tidied history:
(i) the write rule described in an earlier revision as "strengthened"
had in fact been weakened — the exclusive-create requirement on the
final component had been deleted rather than supplemented
(Appendix L.2); (ii) the inbound drop rule described as a pure closure
over schema validation was refuted by this version's own
content_ref-binding fixture, and is now stated as two independent
checks (Section 7.1, Section 13.3); (iii) the claim that this version's
producer bytes are unchanged is explicitly not made — two producer-side
narrowings are disclosed above, each travelling a legible per-artifact
refusal path; and (iv) the 2.1.0 entry below is corrected in place.¶
Fixtures: 58 total (21 valid, 37 invalid), up from 44. The
unknown-member notice fixture moved from the invalid set to the valid set
and was extended with an unrecognized member inside message; valid
unknown-member fixtures were added for result and inbound-message; the
withheld-body invalid fixture was deleted, having become schema-valid
under the relaxed envelope (its name always over-claimed what
additionalProperties alone tested); the bad-provenance fixture moved to
the valid set as an unrecognized-value case; an invalid extra-member
submit-request fixture was added to pin the closed side of the asymmetry,
previously untested at the top level; four invalid fixtures were added
for the address-syntax and control-character rules; a traversal
notice_id fixture was added; the content_ref index-mismatch fixture
was added, and is the one invalid notice-* case that is schema-valid on
its own; and six fixtures pin the trailing-newline anchoring case.¶
A minor bump, prose only: no schema rule and no fixture changed, and
the wire major stayed "2". It resolved an inconsistency carried since
2.0.0. A fixture showing a clean Descriptor with content_ref and
sha256 sat in the valid set as a deliberately forward-valid shape, while
the prose forbade any Runtime from producing it — the gate blessed what
the prose forbade. This version sided with the fixtures: the
release-scoped byte embargo was dropped, and the standing rule became the
one stated in Section 7.2 — a Runtime may publish bytes and emit
content_ref/sha256 only for a Descriptor it asserts clean, and a
Runtime that cannot make that determination must not assert clean. The
substantive clarification is that disposition is the Runtime's
assertion, its basis deliberately unspecified; the older prose presumed
a scanner, which is what made byte access read as a deferred capability
rather than as a policy decision whose wire shape major 2 already carried.
Nothing was tightened, and there is still no outbound content_ref.¶
A permissive minor bump: no wire member, no schema change, no fixture
changed or added. It (a) declared a read-only inbound tree a valid Runtime
posture and made the inbound processed/ archive directory optional, (b)
obliged a Connector not to require inbound write access, making
consumption-recording Connector-private, and (c) named deletion rights per
artifact class, along with the two constraints attached to them: a
Connector must not delete or rewrite a published request, and a Connector
exercising its new deletion rights must preserve req_id monotonicity by
other durable means. It also pinned three previously implicit
reason_code rules: setting one on every Result became a keyworded
SHOULD, and a consumer must tolerate an unrecognized code and must not
infer outcome from it.¶
Recorded at the time as an additive minor bump: one new optional
spooled artifact (the Inbound Message) and its schema, one new optional
Notice member (provenance), and three new optional Result members
(reason_code, attestation, ts). No existing schema gained a required
member, no existing fixture changed shape, and the wire major stayed "2".¶
Correction (recorded at 3.0.0). That "additive minor" label was
unsound as written. Against a strictly validating 2.0.0 consumer — every
schema at that version was closed at every level — a Notice carrying the
new provenance member failed schema validation as a whole document
and was silently dropped: precisely the failure mode cited as the reason
attachments had required a major bump, reintroduced as a minor. Version
3.0.0 repairs the versioning model rather than relabelling this history,
which is why this entry still records what shipped rather than what would
have been correct.¶
contract_version required
A major bump, moving the wire major from "1" to "2". It added
attachment support in both directions — outbound Descriptors with required
size_bytes and sha256 and their Sidecar bytes, inbound Descriptors with
a disposition — and made contract_version REQUIRED on the
submit-request, deliver-notice, and result artifacts, with no
compatibility mode. The reason the bump had to be major rather than minor
is the reasoning in Section 13.1: under the then-universal closed
envelopes, a new member caused a major-1 consumer to reject the whole
document, which inbound is silent total message loss. A version signal,
rather than an unknown-member rejection, is what makes that refusal
legible.¶
Across these versions the fixture suite's identities were normalized to
example.com and example.org placeholders, and deployment-specific
identifiers were removed from fixture content. This changed no shape and
no expectation: every affected fixture validates exactly as before, and no
identifier removed in that pass is reintroduced by this document.¶