mail-address-semantics/mas-modes-v0.1-spec/CONTRACT.md
tegwick e91cd01a02 MAS-WP-0010 T01-T05: mode contract v0.1, schemas, callback stub, email as master
mas-modes-v0.1-spec/ (contract, message + gateway-event schemas, transition
table, message fixtures, statute) vendored into mas_modes. EmailMode
implements the master role over the reference ticket service (MasterStore):
handoff, negotiation by email, statuses in the thread, timeouts, resume by
email, principle 10 shared via mas_modes.mailer.

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 10:57:20 +02:00

167 lines
8.5 KiB
Markdown

# MAS mode contract v0.1 (draft)
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
its equivalence key (address spec §8).
- **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 equivalence key. 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. On `!MODE` to an intake or resource address, email creates or finds the
case. Then it sends `handoff.request` to the mode endpoint configured
for that resource type and mode. If none is configured, or the statute
does not enable the mode, email records a `refused` status itself and
continues by email.
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. Principle 10 (§6) applies.
4. Negotiation: `negotiating` with `needs` makes email ask the participant
by email for the missing details. A reply on the case address that
supplies them 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}`. With `block`, a `!MODE` request is reported as
`handoff.requested` with `"forwarded": false`, and nothing else
happens. With a webhook, email sends `handoff.request` to that URL,
relays every `handoff.status` to the master, and 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}`) |
| `handoff.requested` | email → master | `handoff_id`, `mode`, `forwarded` (bool) |
| `mail.send` | master → email | `in_reply_to` (a `message_ref`), `subject`, `body` |
`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`.
## 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`.
## 7. Conformance
`conformance/` holds valid and invalid messages, the transition table as
data, and scripted flows. Each flow is a sequence of inbound mail,
contract messages and clock ticks, with the expected email-side reaction.
An email-side implementation claims `MAS-MODES-EMAIL-MASTER`
or `MAS-MODES-EMAIL-SLAVE` by passing the flows for that role.