mail-address-semantics/mas-modes-v0.1-spec/CONTRACT.md
tegwick 001cab57d1 Mode contract v0.1 final (MAS-WP-0014 finished)
Founder decision after the independent JS email side. Draft markers
removed (bundle, address spec v0.2 section 7.1, reuse surface), CHANGELOG
v0.1 with MCP-1..6 and known limits. SCOPE: all workplans but MAS-WP-0007
finished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 13:59:27 +02:00

304 lines
16 KiB
Markdown

# MAS mode contract v0.1
How email hands a conversation to another communication mode, and how
email works inside a control flow it does not own. The bundle is
versioned separately from the MAS address specification. In MAS terms,
`ticket!callback@D` *requests* a handoff to `callback` (address spec §7).
This contract defines what can happen to that request.
The keywords MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
## 1. Concepts
- **Case.** A unit of communication. It is addressed by a canonical MAS
resource address (`ticket=gs-1@D`), and identified by that address and a
`key`. Email chooses the key from the address-spec equivalence of the
target (§8), so equivalent addresses get the same key. Other parties
treat the key as opaque and echo it back unchanged (MCP-4).
- **Thread.** The durable record of a case: inbound and outbound mail, plus
every mode status.
- **Mode.** A communication channel other than email (`callback`, `chat`,
...), reached through a **mode endpoint** that speaks this contract.
- **Handoff.** One attempt to continue a case in a mode. It has a
`handoff_id` and a status (§4).
- **Role.** For each resource type, email is either **master** or **slave**
(§3). The role is fixed for the life of a case.
- **Outside master.** In the slave role, the system that owns the control
flow (a CMS, ticketing or support system, or a framework).
## 2. Parties and transport
There are three parties: `email`, `mode` and `master`. Messages are JSON
objects, sent as HTTP `POST` with `Content-Type: application/json`. Every
message carries the envelope:
| Field | Meaning |
|---|---|
| `mas_modes_version` | `"0.1"` |
| `type` | message type (§5) |
| `id` | sender-chosen unique id; the idempotency key |
| `case` | `{address, key}`: canonical resource address and email's opaque case key (§1). Absent only in `case.intake` and `case.assign`. |
- **Idempotency.** A receiver MUST treat a repeated `id` as already
processed, and SHOULD return the original response.
- **Ordering.** `handoff.status` carries `seq`, increasing per handoff. A
receiver MUST ignore a status whose `seq` is not greater than the last
one it accepted, and answers `{"ok": true, "ignored": "stale"}`.
- **Responses.** `2xx` with `{"ok": true, ...}` means the message was
accepted. `4xx` with `{"ok": false, "error": CODE, "detail": ...}` means
it was refused and MUST NOT be retried unchanged. `5xx` or no answer
means the sender MAY retry with the same `id`.
## 3. Roles
### 3.1 Email as master
Email owns the case, the thread and the timeouts.
1. Mail to an intake address creates a case. Email then sends the
participant one **acknowledgement** from the case address, so they learn
it (MCP-1); principle 10 applies. Mail to the case address is recorded
on that case.
On `!MODE` to an intake or resource address, email creates or finds the
case and sends `handoff.request` to the mode endpoint configured for
that resource type and mode. If the statute does not enable the mode,
email records `refused` (`mode-not-enabled`) itself. For a new case,
the acknowledgement says so and no separate resume mail follows. If no
endpoint is configured, or the endpoint refuses or cannot be reached,
email records `refused` (`mode-unavailable`) and resumes (item 3).
2. Email sets a **deadline** for each handoff. If no terminal status
(§4) has arrived by then, email records `timed-out`, sends
`handoff.cancel` to the mode, and resumes by email.
3. Every status is recorded in the thread. On every terminal status, email
**resumes**: it sends the case's participant one mail from the case
address, saying what happened. The mail MUST name the outcome: the
terminal status as written in §4 (`completed`, `timed-out`, ...) and,
when present, the `reason` or `detail` (MCP-3). Principle 10 (§6)
applies.
4. **Details** (MCP-2). A detail is a line `Field: value` in a mail body.
Field names are compared ignoring case, and the first occurrence wins.
Email reads the fields the role configuration names (`fields`) from the
mail that requests a handoff, and puts them in `handoff.request`'s
`details`. `negotiating` with `needs` makes email ask the participant
with one mail that lists each needed field on its own line as
`Field: ...`, with the field name capitalized. A later mail on the case
that supplies needed fields is sent to the mode as `handoff.update`.
### 3.2 Email as slave
The outside master owns the control flow, case identifiers and timeouts.
1. **Case identifiers come from the master.** Intake mail (no case yet) is
reported as `case.intake` with an `intake_ref`. The master answers with
`case.assign`, giving the resource id, which email adopts as the case
address `TYPE=ID@D`. Email MUST NOT invent case identifiers in this
role. Until assigned, the intake stays pending. `case.assign` MAY carry
the case's initial `policy`. A mode requested in the intake mail
(`requested_mode`) is handled under that policy once the case exists.
2. Inbound mail on a case is reported as `case.message`.
3. **Sending.** Email sends only on `mail.send` from the master, unless the
case policy (`case.policy`) sets `autonomous_send: true`. Then email
MAY also send acknowledgements and negotiation questions itself. A
`mail.send` is always a reply to a message already recorded on the case
(`in_reply_to`): the recipient is that message's sender. A requested
send can still be refused under §6, and the refusal is the response.
Policy fields the master does not set take the email side's defaults
for the resource type.
4. **Transfers.** The case policy sets `transfers` to `"block"` or to
`{"webhook": URL, "peer": ID}` (the peer must be known to email, §7.3).
With `block`, a `!MODE` request is reported as `handoff.requested` with
`"forwarded": false`, followed by one `handoff.status` `refused` with
reason `blocked`, and nothing else happens (MCP-6). With a webhook,
email sends `handoff.request` to that URL, reports `handoff.requested`
with `"forwarded": true`, and relays every status the mode sends (and
one it records itself, such as `mode-unavailable`) as `handoff.status`.
It numbers them with its own `seq`. Email's own `requested` is not
relayed: `handoff.requested` stands for it. Email does the negotiation
(3.1 item 4) only when `autonomous_send` is true.
5. **Timeouts belong to the master.** Email sets no deadline and never
records `timed-out`. The master MAY end a handoff with `handoff.cancel`.
## 4. Handoff status machine
| From | Allowed next statuses |
|---|---|
| (new) | `requested` |
| `requested` | `negotiating`, `scheduled`, `running`, `refused`, `failed`, `cancelled`, `timed-out` |
| `negotiating` | `negotiating`, `scheduled`, `refused`, `expired`, `cancelled`, `timed-out` |
| `scheduled` | `running`, `attempted`, `expired`, `cancelled`, `timed-out` |
| `attempted` | `scheduled`, `failed`, `expired`, `cancelled`, `timed-out` |
| `running` | `completed`, `failed`, `cancelled`, `timed-out` |
**Terminal** statuses are `completed`, `failed`, `expired`, `refused`,
`cancelled` and `timed-out`. Nothing follows a terminal status.
- `requested`: recorded by email when it sends `handoff.request`.
- `negotiating`: the mode needs details. `needs` lists field names
(`phone`, `time`, ...).
- `scheduled`: has `at` (RFC 3339) when known. Going from `attempted` to
`scheduled` is a retry.
- `attempted`: an attempt did not reach the participant. `outcome`, e.g.
`no-answer`.
- `refused`: has `reason`, one of `mode-unavailable`, `mode-not-enabled`,
`declined`, `out-of-hours`, `blocked`.
- `timed-out`: recorded only by the timeout owner: email as master, or the
master's own bookkeeping. A mode MUST NOT send it.
- `cancelled`: after `handoff.cancel`.
A receiver MUST refuse a transition that the table does not allow
(`error: invalid-transition`).
## 5. Message types
| Type | From → to | Payload |
|---|---|---|
| `handoff.request` | email → mode | `handoff_id`, `mode`, `details` (known fields), `status_url` (where statuses go), `participant` (`{address}`) |
| `handoff.update` | email → mode | `handoff_id`, `details` |
| `handoff.cancel` | email → mode, master → email | `handoff_id`, `reason` |
| `handoff.status` | mode → email, email → master (relay) | `handoff_id`, `seq`, `status`, plus `needs` / `at` / `outcome` / `reason` / `detail` |
| `case.intake` | email → master | `intake_ref`, `resource_type`, `event` (gateway event), `requested_mode` |
| `case.assign` | master → email | `intake_ref`, `resource_id`, optional `policy` (`{autonomous_send, transfers}`) |
| `case.message` | email → master | `event`, `message_ref` |
| `case.policy` | master → email | `autonomous_send` (bool), `transfers` (`"block"` or `{"webhook": URL, "peer": ID}`) |
| `handoff.requested` | email → master | `handoff_id`, `mode`, `forwarded` (bool) |
| `mail.send` | master → email | `in_reply_to` (a `message_ref`), `subject`, `body` |
`status_url` contains the per-handoff token (§7.3). `event` is the normalized gateway event (`schema/mas-event-v0.1.schema.json`).
`message_ref` is the email side's id for a recorded inbound message.
`mail.send` responses: `{"ok": true, "sent": true}`, or `{"ok": false,
"error": "refused", "reason": R}` with R one of `unauthenticated-sender`,
`automated-sender`, `send-cap-reached`, `unknown-message`. With
`send-cap-reached` the response also carries `held` (bool, §6).
`case.assign` responds `{"ok": true, "case": {address, key},
"message_ref": M}`, where M is the intake message, now recorded on the
case. When `autonomous_send` is on, the response also has `acknowledged`
(bool). When the intake requested a mode, it has `handoff_id`.
### 5.1 Other errors
Besides §7.4, a receiver answers with these codes (MCP-5):
| Code | HTTP | Meaning |
|---|---|---|
| `schema` | 400 | the message does not validate against the schema |
| `invalid-resource-id` | 400 | `case.assign` id does not make a valid resource address |
| `unknown-handoff` | 404 | no such handoff at this URL, or the `handoff_id` / `case` do not match it |
| `unknown-case` | 404 | no case with this key |
| `unknown-intake` | 404 | no intake with this `intake_ref` |
| `invalid-transition` | 409 | the status may not follow the current one (§4) |
| `already-assigned` | 409 | the intake already has a case |
| `case-exists` | 409 | a case with the same key exists |
| `refused` | 409 | `mail.send` not sent, with `reason` (§5) |
## 6. Principle 10 in both roles
Email MUST NOT send mail to a sender that did not pass DMARC (as read
from trusted `Authentication-Results`), to automated mail (`Auto-Submitted`
other than `no`, or `Precedence: bulk | list | junk`), or to the null
sender. It MUST NOT send once its send cap is reached. These rules hold
whoever asks for the send: email itself as master, the case's autonomous
policy, or a master's `mail.send`.
The send cap is **one cap per deployment**. Every mail email sends, in
either role and from any application behind it, counts against the same
total. Over the cap, a mail is **held**: recorded unsent, and reported to
whoever asked as `send-cap-reached`. Held mail MUST be deleted after a
configured maximum age (default 7 days). It MUST NOT be stored once held
mail would exceed a configured maximum size (default 100 MB); the mail is
then reported as not held. Mail actually sent is never purged, because it
is what the cap counts.
## 7. Peer authentication and transport security
Contract messages steer real mail (`mail.send`, `case.assign`,
`case.policy`) and change what customers are told (`handoff.status`).
Every party MUST therefore know who sent a message before acting on it.
### 7.1 Peers and keys
Each party has a **peer id** (`email`, `cms-1`, `callback-1`, ...).
Every pair of parties that exchange messages shares a secret key of at
least 32 random octets. Keys are pairwise: email holds one key per master,
mode endpoint and gateway it talks to, and the other party holds the same
key for email. Keys are configuration, never part of a message.
### 7.2 Signed requests (MUST)
Every contract request MUST carry three HTTP headers. Gateway events
(gateway → email) MUST carry them when the gateway and the email side are
on different hosts. A deployment MAY leave them off for events on
loopback; the email side is then configured to accept unsigned events,
and only from loopback.
| Header | Value |
|---|---|
| `MAS-Modes-Peer` | the sender's peer id |
| `MAS-Modes-Timestamp` | the time of sending, as integer Unix seconds |
| `MAS-Modes-Signature` | `v1=` followed by the lower-case hex HMAC-SHA256, keyed with the pair's key, of `TIMESTAMP + "." + BODY`, where BODY is the exact request body octets |
The receiver MUST:
1. look up the key for `MAS-Modes-Peer`. If the peer is unknown, refuse
with `401 unauthenticated-peer`;
2. recompute the signature over the received octets and compare it in
constant time. On a mismatch or missing header, refuse with
`401 unauthenticated-peer`;
3. refuse a timestamp more than 300 seconds from its own clock with
`401 replayed`;
4. only then parse and act on the body. A repeated message `id` inside
the window gets the original response (§2), so a captured request
cannot cause a second effect.
Responses are not signed. They travel on the connection the signer opened.
Mutual TLS MAY be used in addition, but it does not replace signing: the
peer id and the authorization in §7.3 come from the signed headers.
### 7.3 Authorization
Authentication says who sent a message. Email then also checks that this
peer may send it:
- **Master commands** (`case.assign`, `case.policy`, `mail.send`,
`handoff.cancel` to email) are accepted only from the peer configured
as master for the case's resource type (for `case.assign`, the intake's
resource type). Anyone else gets `403 wrong-peer`.
- **Mode statuses.** Each handoff is bound to the peer it was sent to.
`handoff.request` carries a `status_url` that contains an unguessable
per-handoff token (at least 128 bits). Email accepts a `handoff.status`
only at that URL, and only from that peer (`403 wrong-peer`). A
`handoff_id` alone is not enough.
- **Primed webhooks** (slave role). `transfers.webhook` MUST name the
receiving mode's `peer`, which MUST be a peer email already has a key
for. Otherwise `case.policy` and `case.assign` are refused with
`400 unknown-peer`. A master can therefore direct handoffs only to
modes the email operator has provisioned. It cannot make email sign
requests to an arbitrary URL with a key it chose.
- **Mode endpoints** accept `handoff.request`, `handoff.update` and
`handoff.cancel` only from email's peer id.
- **Masters** accept `case.*`, `handoff.requested` and relayed
`handoff.status` only from email's peer id.
### 7.4 Errors
| Code | HTTP | Meaning |
|---|---|---|
| `unauthenticated-peer` | 401 | unknown peer, missing or bad signature |
| `replayed` | 401 | timestamp outside the window |
| `wrong-peer` | 403 | authenticated, but not allowed to send this |
| `unknown-peer` | 400 | a webhook names a peer email has no key for |
A sender MUST NOT retry `401` or `403` unchanged.
## 8. Conformance
`conformance/` holds valid and invalid messages, the transition table as
data, the callback scenarios (`scenarios.json`), and scripted flows. Each
flow is a sequence of inbound mail, contract messages and clock ticks,
with the expected email-side reaction, defined in `conformance/FLOWS.md`
and runnable from the bundle alone.
`conformance/auth.json` holds signing vectors and verification cases for
section 7; every party MUST pass them. Flows `a-*` check authorization at
the email side. An email-side implementation claims `MAS-MODES-EMAIL-MASTER`
or `MAS-MODES-EMAIL-SLAVE` by passing the flows for that role.