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
167 lines
8.5 KiB
Markdown
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.
|