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
304 lines
16 KiB
Markdown
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.
|