mail-address-semantics/mas_modes/conformance/FLOWS.md
tegwick 71c17fb5f9 MAS-WP-0014 T01: independent JS email side for mode contract v0.1; MCP-1..6; flow a-008
impl/js/src/email.mjs (written from CONTRACT.md + FLOWS.md): both roles,
case store, handoffs, timeouts, one-cap outbox with holding, authorization.
Passes all 22 flows from the bundle; with the JS master all 12 slave
flows (no Python contract party). Writing it found six contract gaps,
folded into the draft as MCP-1..6 (acknowledgement, details convention,
resume names the outcome, opaque case key, error table, transfer
reporting); flows now check outcome tokens, not reference prose. A
mutation check found a missing flow: a-008 (per-type master check).

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:56:53 +02:00

95 lines
5.1 KiB
Markdown

# Conformance flows (mode contract v0.1)
`flows.json` describes complete conversations that an email-side
implementation must handle. A run needs only this bundle: `flows.json`,
`scenarios.json` and `statute.json`. Every expectation is observable at
the contract boundary: messages the stub parties receive, responses to
contract requests, and mail the email side sends. Nothing depends on how an
implementation stores data or answers the gateway.
## Running a flow
For each flow, start fresh:
1. **Email side.** An email side for the statute in `statute.json`
(namespace `modes.example`), configured with the flow's `setup`, i.e.
`setups[flow.setup].roles`. Its own peer id is `email`, and it knows the
keys in `peers`. A master role needs a store for its cases. The
reference uses its ticket service, but any store that creates a case on
intake mail and correlates mail to `ticket=ID` works.
2. **Callback stub.** Peer `callback-1` speaks the contract and plays
`scenarios.json`, with `flow.scenario` as its default. It sends nothing
until a `mode` step.
3. **Outside master stub.** Peer `cms-1` records every message it gets,
and sends commands only in `master` steps. Other masters named in a
setup (`cms-2`) are only claimed in `inject` steps, signed with their
key from `peers`.
4. **Clock.** A controllable clock for the email side's timeouts. Only
`advance` steps move it. Signatures use real time.
5. **Send cap.** When `flow.max_sent` is set, the email side's send cap is
that number, and an SMTP sink counts what is actually sent. Without it
there is no cap.
Role configuration fields are those of CONTRACT.md §3. In a setup:
- `modes: {MODE: {"peer": ID}}` means the mode endpoint of that peer;
- `master_peer` is the outside master, which implies its event URL;
- `"<mode:ID>"` stands for the handoff URL of peer ID.
The implementation wires peer ids to its own URLs. The `peers` keys are
public **test keys**, never to be used in a deployment.
## Steps
| Step | Meaning |
|---|---|
| `{"mail": {to, text, dmarc?, subject?}}` | Inbound mail from `customer@example.net` to `TO@modes.example`, through the normal gateway path. `dmarc` (default `"pass"`) is the trusted Authentication-Results verdict; `null` means no verdict. |
| `{"mode": "run"}` | The callback stub sends every status that is due, until it is waiting, silent or terminal. |
| `{"advance": N}` | Move the email side's clock N seconds and let it run its timeouts. |
| `{"master": {"command": C, ...}}` | The stub master sends a signed contract command: `assign` (`case.assign`; `resource_id`, plus `intake_ref`, by default the captured one), `send` (`mail.send`; `in_reply_to`, `body`), `cancel` (`handoff.cancel`; `handoff_id`), or `policy` (`case.policy`). The case is the captured case. |
| `{"inject": {endpoint, as?, sign?, token?, message}}` | Send a crafted request straight to the email side: `endpoint` is `master`, `status` (the captured handoff's status URL) or `events`. `as` is the claimed peer id. `sign` is `valid` (default), `none`, `stale` (timestamp 400 s old) or `wrong-key`. `token: "bogus"` replaces the status URL token. In `message`, `"case": "current"` means the captured case, and the envelope (`mas_modes_version`, `id`) is added. |
Strings in steps may contain the captures `{case_id}`, `{message_ref}`,
`{intake_ref}` and `{handoff_id}`.
## Captures
Captures are updated after every step, from what that step produced, in
this order:
1. every message the mode stub and the master stub received;
2. the response to a `master` or `inject` step.
The latest value wins.
| Capture | From |
|---|---|
| case (`address`, `key`) | the `case` of any of those messages or responses |
| `{case_id}` | the resource id in the captured case address (`TYPE=ID@D`) |
| `{message_ref}` | `message_ref` of `case.message`, or of the `case.assign` response |
| `{intake_ref}` | `intake_ref` of `case.intake` |
| `{handoff_id}` | `handoff_id` of `handoff.request` (mode) or `handoff.requested` (master) |
## Expectations
`expect` is checked after the step. Every key listed must match.
| Key | Value |
|---|---|
| `event_accepted` | the email side answered the step's inbound mail event with 2xx |
| `mode_statuses` | statuses of every `handoff.status` the mode stub sent, in order |
| `mode_received` | number of contract messages the mode stub received |
| `mode_received_last` | `type` of the last one |
| `master_received` | `{type: count}` of messages the master stub received; only the listed types are compared |
| `relayed` | statuses of every `handoff.status` the master stub received, in order |
| `mails_sent` | number of mails the email side sent (held and refused mail excluded) |
| `last_mail_contains` | the body of the last mail sent contains this text |
| `response_ok` | `ok` in the response to the step's command |
| `response_reason` | `reason` in that response |
| `response_held` | `held` in that response (CONTRACT.md §6) |
| `case_address` | `case.address` in that response |
| `http_status` | HTTP status of that response |
| `error` | `error` in that response |
A runner MUST reject a flow that uses a step or expectation key not
defined here.