MAS-WP-0013 finished: mode contract flows runnable from the bundle alone

conformance/FLOWS.md defines setup, steps, captures and expectations, all
observable at the contract boundary. flows.json carries setups (peer ids),
fixed test keys, statute, scenario and explicit assign steps;
scenarios.json holds the callback scenarios (the stub loads them).
CONTRACT.md defines the case.assign response. Runner reads only the
bundle and rejects undefined keys; new flow s-006 (held over cap).

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
This commit is contained in:
tegwick 2026-10-19 12:49:15 +02:00
parent 8de8fddf37
commit ecb1ba47f6
14 changed files with 1118 additions and 269 deletions

View file

@ -150,6 +150,11 @@ A receiver MUST refuse a transition that the table does not allow
`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`.
## 6. Principle 10 in both roles
Email MUST NOT send mail to a sender that did not pass DMARC (as read
@ -252,8 +257,10 @@ A sender MUST NOT retry `401` or `403` unchanged.
## 8. 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.
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`

View file

@ -15,7 +15,10 @@ separately from the MAS address specification (`mas-v0.2-spec/`, §7).
- `conformance/transitions.json`: the status machine as data
- `conformance/messages.json`: valid and invalid messages
- `conformance/flows.json`: scripted flows for `MAS-MODES-EMAIL-MASTER` and
`MAS-MODES-EMAIL-SLAVE`
`MAS-MODES-EMAIL-SLAVE`, with setups and test keys; runnable from the
bundle alone
- `conformance/FLOWS.md`: the flow format: steps, captures and expectations
- `conformance/scenarios.json`: the callback stub's scenarios
- `conformance/auth.json`: signing vectors and verification cases (§7)
- `conformance/statute.json`: the statute the flows run under

View file

@ -0,0 +1,93 @@
# 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.
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.

View file

@ -1,9 +1,58 @@
{
"description": "Scripted flows (CONTRACT.md section 7). Each runs against a fresh email side with the callback stub and, for 'slave', a stub outside master that assigns EXT-100, EXT-101, ... The statute is statute.json. Placeholders: {case_id}, {message_ref}, {handoff_id} come from the latest response that carried them; {callback} is the stub's handoff URL. Peers: email, callback-1 (the stub), cms-1 (the stub master); every request is signed (CONTRACT.md section 7). Master role config: ticket, modes {callback}, fields phone/time, timeout 3600 s. Slave role config: case, defaults autonomous_send false, transfers block, overridden by 'config'.",
"description": "Scripted flows (CONTRACT.md section 8). The format, steps, expectations and captures are defined in conformance/FLOWS.md. Everything a run needs is in this file, scenarios.json and statute.json.",
"statute": "statute.json",
"peers": {
"callback-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d63616c6c6261636b31",
"cms-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d636d732d312d2d2d2d",
"gateway": "6d61732d6d6f6465732d76302e312d746573742d6b65792d676174657761792d2d"
},
"setups": {
"master": {
"roles": {
"ticket": {
"role": "master",
"modes": {
"callback": {
"peer": "callback-1"
}
},
"fields": [
"phone",
"time",
"scenario"
],
"timeout": 3600
}
}
},
"slave": {
"roles": {
"case": {
"role": "slave",
"master_peer": "cms-1",
"autonomous_send": false,
"transfers": "block"
}
}
},
"slave-webhook": {
"roles": {
"case": {
"role": "slave",
"master_peer": "cms-1",
"autonomous_send": false,
"transfers": {
"webhook": "<mode:callback-1>",
"peer": "callback-1"
}
}
}
}
},
"flows": [
{
"id": "m-001",
"role": "master",
"setup": "master",
"title": "callback completes",
"scenario": "completed",
"steps": [
@ -13,13 +62,14 @@
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "created"
"event_accepted": true,
"mode_received": 1
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"scheduled",
"running",
"completed"
@ -31,7 +81,7 @@
},
{
"id": "m-002",
"role": "master",
"setup": "master",
"title": "negotiation over email",
"scenario": "negotiate",
"steps": [
@ -41,13 +91,13 @@
"text": "Please call me."
},
"expect": {
"response_status": "created"
"event_accepted": true
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"negotiating"
],
"last_mail_contains": "Phone: ..."
@ -59,13 +109,14 @@
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "correlated"
"event_accepted": true,
"mode_received_last": "handoff.update"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"negotiating",
"scheduled",
"running",
@ -78,7 +129,7 @@
},
{
"id": "m-003",
"role": "master",
"setup": "master",
"title": "no answer, retried, completed",
"scenario": "no-answer-retry",
"steps": [
@ -91,7 +142,7 @@
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"scheduled",
"attempted",
"scheduled",
@ -104,7 +155,7 @@
},
{
"id": "m-004",
"role": "master",
"setup": "master",
"title": "no answer twice, failed",
"scenario": "no-answer-fail",
"steps": [
@ -117,7 +168,7 @@
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"scheduled",
"attempted",
"scheduled",
@ -131,7 +182,7 @@
},
{
"id": "m-005",
"role": "master",
"setup": "master",
"title": "refused out of hours",
"scenario": "out-of-hours",
"steps": [
@ -144,7 +195,7 @@
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"refused"
],
"last_mail_contains": "out-of-hours"
@ -154,7 +205,7 @@
},
{
"id": "m-006",
"role": "master",
"setup": "master",
"title": "email owns the timeout",
"scenario": "silent",
"steps": [
@ -162,33 +213,44 @@
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"mails_sent": 1
}
},
{
"mode": "run",
"expect": {
"statuses": []
"mode_statuses": []
}
},
{
"advance": 3599,
"expect": {
"timed_out": 0
"mode_received": 1,
"mails_sent": 1
}
},
{
"advance": 2,
"expect": {
"timed_out": 1,
"mode_received_last": "handoff.cancel",
"mails_sent": 2,
"last_mail_contains": "could not arrange"
}
},
{
"advance": 100000,
"expect": {
"mode_received": 2,
"mails_sent": 2
}
}
]
},
{
"id": "m-007",
"role": "master",
"setup": "master",
"title": "mode not enabled by the statute",
"scenario": "completed",
"steps": [
@ -198,16 +260,16 @@
"text": "hi"
},
"expect": {
"response_status": "created",
"event_accepted": true,
"mode_received": 0,
"outbox_count": 1
"mails_sent": 1
}
}
]
},
{
"id": "m-008",
"role": "master",
"setup": "master",
"title": "unauthenticated sender gets no mail",
"scenario": "negotiate",
"steps": [
@ -218,24 +280,25 @@
"dmarc": "fail"
},
"expect": {
"response_status": "created"
"event_accepted": true
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"negotiating"
],
"outbox_count": 0
"mails_sent": 0
}
}
]
},
{
"id": "s-001",
"role": "slave",
"setup": "slave",
"title": "master issues the id; email sends only on instruction",
"scenario": "completed",
"steps": [
{
"mail": {
@ -243,16 +306,20 @@
"text": "My order is late"
},
"expect": {
"response_status": "intake-reported",
"event_accepted": true,
"master_received": {
"case.intake": 1
},
"outbox_count": 0
"mails_sent": 0
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"response_ok": true,
"case_address": "case=EXT-100@modes.example"
}
},
@ -262,11 +329,10 @@
"text": "more"
},
"expect": {
"response_status": "reported",
"master_received": {
"case.message": 1
},
"outbox_count": 0
"mails_sent": 0
}
},
{
@ -276,16 +342,17 @@
"body": "We are on it."
},
"expect": {
"command_ok": true,
"outbox_count": 1
"response_ok": true,
"mails_sent": 1
}
}
]
},
{
"id": "s-002",
"role": "slave",
"setup": "slave",
"title": "principle 10 refuses a master send",
"scenario": "completed",
"steps": [
{
"mail": {
@ -295,7 +362,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"master": {
@ -304,31 +374,43 @@
"body": "x"
},
"expect": {
"command_ok": false,
"command_reason": "unauthenticated-sender",
"outbox_count": 0
"response_ok": false,
"response_reason": "unauthenticated-sender",
"mails_sent": 0
}
}
]
},
{
"id": "s-003",
"role": "slave",
"setup": "slave",
"title": "transfer blocked",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"master_received": {
"case.intake": 1
}
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"master_received": {
"handoff.requested": 1,
"handoff.status": 1
},
"relayed": [
"refused"
],
"mode_received": 0
}
}
@ -336,14 +418,8 @@
},
{
"id": "s-004",
"role": "slave",
"setup": "slave-webhook",
"title": "transfer to the primed webhook; statuses relayed",
"config": {
"transfers": {
"webhook": "{callback}",
"peer": "callback-1"
}
},
"scenario": "completed",
"steps": [
{
@ -353,7 +429,10 @@
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"mode_received": 1
}
@ -366,27 +445,21 @@
"running",
"completed"
],
"outbox_count": 0
"mails_sent": 0
}
},
{
"advance": 1000000,
"expect": {
"timed_out": 0
"mode_received": 1
}
}
]
},
{
"id": "s-005",
"role": "slave",
"setup": "slave-webhook",
"title": "the master owns the timeout",
"config": {
"transfers": {
"webhook": "{callback}",
"peer": "callback-1"
}
},
"scenario": "silent",
"steps": [
{
@ -396,7 +469,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"mode": "run",
@ -404,13 +480,19 @@
"relayed": []
}
},
{
"advance": 1000000,
"expect": {
"mode_received": 1
}
},
{
"master": {
"command": "cancel",
"handoff_id": "{handoff_id}"
},
"expect": {
"command_ok": true,
"response_ok": true,
"mode_received_last": "handoff.cancel"
}
},
@ -425,9 +507,10 @@
]
},
{
"id": "a-001",
"role": "slave",
"title": "unsigned master command refused",
"id": "s-006",
"setup": "slave",
"title": "over the cap a master send is held",
"scenario": "completed",
"steps": [
{
"mail": {
@ -436,7 +519,55 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "one"
},
"expect": {
"response_ok": true,
"mails_sent": 1
}
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "two"
},
"expect": {
"response_ok": false,
"response_reason": "send-cap-reached",
"response_held": true,
"mails_sent": 1
}
}
],
"max_sent": 1
},
{
"id": "a-001",
"setup": "slave",
"title": "unsigned master command refused",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
}
},
{
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -452,23 +583,21 @@
"expect": {
"http_status": 401,
"error": "unauthenticated-peer",
"outbox_count": 0
"mails_sent": 0
}
}
]
},
{
"id": "a-002",
"role": "slave",
"setup": "slave",
"title": "a mode cannot assign case ids",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
},
"expect": {
"response_status": "intake-reported"
}
},
{
@ -487,7 +616,10 @@
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"case_address": "case=EXT-100@modes.example"
}
@ -496,8 +628,9 @@
},
{
"id": "a-003",
"role": "slave",
"setup": "slave",
"title": "webhook to a peer email has no key for",
"scenario": "completed",
"steps": [
{
"mail": {
@ -506,7 +639,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -530,8 +666,9 @@
},
{
"id": "a-004",
"role": "slave",
"setup": "slave",
"title": "replayed master command",
"scenario": "completed",
"steps": [
{
"mail": {
@ -540,7 +677,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -557,15 +697,16 @@
"expect": {
"http_status": 401,
"error": "replayed",
"outbox_count": 0
"mails_sent": 0
}
}
]
},
{
"id": "a-005",
"role": "slave",
"setup": "slave",
"title": "master command signed with the wrong key",
"scenario": "completed",
"steps": [
{
"mail": {
@ -574,7 +715,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -591,7 +735,7 @@
"expect": {
"http_status": 401,
"error": "unauthenticated-peer",
"outbox_count": 0
"mails_sent": 0
}
},
{
@ -607,14 +751,14 @@
},
"expect": {
"http_status": 200,
"outbox_count": 1
"mails_sent": 1
}
}
]
},
{
"id": "a-006",
"role": "master",
"setup": "master",
"title": "statuses only from the handoff's peer, at its URL",
"scenario": "silent",
"steps": [
@ -624,7 +768,7 @@
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"outbox_count": 1
"mails_sent": 1
}
},
{
@ -634,6 +778,7 @@
"message": {
"type": "handoff.status",
"case": "current",
"handoff_id": "{handoff_id}",
"seq": 1,
"status": "completed"
}
@ -651,6 +796,7 @@
"message": {
"type": "handoff.status",
"case": "current",
"handoff_id": "{handoff_id}",
"seq": 1,
"status": "completed"
}
@ -658,7 +804,7 @@
"expect": {
"http_status": 404,
"error": "unknown-handoff",
"outbox_count": 1
"mails_sent": 1
}
},
{
@ -668,6 +814,7 @@
"message": {
"type": "handoff.status",
"case": "current",
"handoff_id": "{handoff_id}",
"seq": 1,
"status": "scheduled",
"at": "x"
@ -681,8 +828,9 @@
},
{
"id": "a-007",
"role": "master",
"setup": "master",
"title": "only the gateway posts events",
"scenario": "completed",
"steps": [
{
"inject": {

View file

@ -0,0 +1,120 @@
{
"description": "Callback stub scenarios (CONTRACT.md section 8). Each is the sequence of handoff.status messages the stub sends, one per step. A step {'wait_for_details': [fields]} sends 'negotiating' with needs = the missing fields and waits until handoff.update supplies them; it is skipped when the handoff.request already carried them. After handoff.cancel the stub sends 'cancelled' next. 'silent' never answers.",
"default": "completed",
"scenarios": {
"completed": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "running"
},
{
"status": "completed"
}
],
"negotiate": [
{
"wait_for_details": [
"phone",
"time"
]
},
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "running"
},
{
"status": "completed"
}
],
"no-answer-retry": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "attempted",
"outcome": "no-answer"
},
{
"status": "scheduled",
"at": "2026-10-20T14:00:00Z"
},
{
"status": "running"
},
{
"status": "completed"
}
],
"no-answer-fail": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "attempted",
"outcome": "no-answer"
},
{
"status": "scheduled",
"at": "2026-10-20T14:00:00Z"
},
{
"status": "attempted",
"outcome": "no-answer"
},
{
"status": "failed",
"detail": "two attempts, no answer"
}
],
"call-failed": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "running"
},
{
"status": "failed",
"detail": "line dropped"
}
],
"expired": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "expired",
"detail": "slot passed"
}
],
"unavailable": [
{
"status": "refused",
"reason": "mode-unavailable"
}
],
"declined": [
{
"status": "refused",
"reason": "declined"
}
],
"out-of-hours": [
{
"status": "refused",
"reason": "out-of-hours"
}
],
"silent": []
}
}

View file

@ -0,0 +1,93 @@
# 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.
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.

View file

@ -1,9 +1,58 @@
{
"description": "Scripted flows (CONTRACT.md section 7). Each runs against a fresh email side with the callback stub and, for 'slave', a stub outside master that assigns EXT-100, EXT-101, ... The statute is statute.json. Placeholders: {case_id}, {message_ref}, {handoff_id} come from the latest response that carried them; {callback} is the stub's handoff URL. Peers: email, callback-1 (the stub), cms-1 (the stub master); every request is signed (CONTRACT.md section 7). Master role config: ticket, modes {callback}, fields phone/time, timeout 3600 s. Slave role config: case, defaults autonomous_send false, transfers block, overridden by 'config'.",
"description": "Scripted flows (CONTRACT.md section 8). The format, steps, expectations and captures are defined in conformance/FLOWS.md. Everything a run needs is in this file, scenarios.json and statute.json.",
"statute": "statute.json",
"peers": {
"callback-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d63616c6c6261636b31",
"cms-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d636d732d312d2d2d2d",
"gateway": "6d61732d6d6f6465732d76302e312d746573742d6b65792d676174657761792d2d"
},
"setups": {
"master": {
"roles": {
"ticket": {
"role": "master",
"modes": {
"callback": {
"peer": "callback-1"
}
},
"fields": [
"phone",
"time",
"scenario"
],
"timeout": 3600
}
}
},
"slave": {
"roles": {
"case": {
"role": "slave",
"master_peer": "cms-1",
"autonomous_send": false,
"transfers": "block"
}
}
},
"slave-webhook": {
"roles": {
"case": {
"role": "slave",
"master_peer": "cms-1",
"autonomous_send": false,
"transfers": {
"webhook": "<mode:callback-1>",
"peer": "callback-1"
}
}
}
}
},
"flows": [
{
"id": "m-001",
"role": "master",
"setup": "master",
"title": "callback completes",
"scenario": "completed",
"steps": [
@ -13,13 +62,14 @@
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "created"
"event_accepted": true,
"mode_received": 1
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"scheduled",
"running",
"completed"
@ -31,7 +81,7 @@
},
{
"id": "m-002",
"role": "master",
"setup": "master",
"title": "negotiation over email",
"scenario": "negotiate",
"steps": [
@ -41,13 +91,13 @@
"text": "Please call me."
},
"expect": {
"response_status": "created"
"event_accepted": true
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"negotiating"
],
"last_mail_contains": "Phone: ..."
@ -59,13 +109,14 @@
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "correlated"
"event_accepted": true,
"mode_received_last": "handoff.update"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"negotiating",
"scheduled",
"running",
@ -78,7 +129,7 @@
},
{
"id": "m-003",
"role": "master",
"setup": "master",
"title": "no answer, retried, completed",
"scenario": "no-answer-retry",
"steps": [
@ -91,7 +142,7 @@
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"scheduled",
"attempted",
"scheduled",
@ -104,7 +155,7 @@
},
{
"id": "m-004",
"role": "master",
"setup": "master",
"title": "no answer twice, failed",
"scenario": "no-answer-fail",
"steps": [
@ -117,7 +168,7 @@
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"scheduled",
"attempted",
"scheduled",
@ -131,7 +182,7 @@
},
{
"id": "m-005",
"role": "master",
"setup": "master",
"title": "refused out of hours",
"scenario": "out-of-hours",
"steps": [
@ -144,7 +195,7 @@
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"refused"
],
"last_mail_contains": "out-of-hours"
@ -154,7 +205,7 @@
},
{
"id": "m-006",
"role": "master",
"setup": "master",
"title": "email owns the timeout",
"scenario": "silent",
"steps": [
@ -162,33 +213,44 @@
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"mails_sent": 1
}
},
{
"mode": "run",
"expect": {
"statuses": []
"mode_statuses": []
}
},
{
"advance": 3599,
"expect": {
"timed_out": 0
"mode_received": 1,
"mails_sent": 1
}
},
{
"advance": 2,
"expect": {
"timed_out": 1,
"mode_received_last": "handoff.cancel",
"mails_sent": 2,
"last_mail_contains": "could not arrange"
}
},
{
"advance": 100000,
"expect": {
"mode_received": 2,
"mails_sent": 2
}
}
]
},
{
"id": "m-007",
"role": "master",
"setup": "master",
"title": "mode not enabled by the statute",
"scenario": "completed",
"steps": [
@ -198,16 +260,16 @@
"text": "hi"
},
"expect": {
"response_status": "created",
"event_accepted": true,
"mode_received": 0,
"outbox_count": 1
"mails_sent": 1
}
}
]
},
{
"id": "m-008",
"role": "master",
"setup": "master",
"title": "unauthenticated sender gets no mail",
"scenario": "negotiate",
"steps": [
@ -218,24 +280,25 @@
"dmarc": "fail"
},
"expect": {
"response_status": "created"
"event_accepted": true
}
},
{
"mode": "run",
"expect": {
"statuses": [
"mode_statuses": [
"negotiating"
],
"outbox_count": 0
"mails_sent": 0
}
}
]
},
{
"id": "s-001",
"role": "slave",
"setup": "slave",
"title": "master issues the id; email sends only on instruction",
"scenario": "completed",
"steps": [
{
"mail": {
@ -243,16 +306,20 @@
"text": "My order is late"
},
"expect": {
"response_status": "intake-reported",
"event_accepted": true,
"master_received": {
"case.intake": 1
},
"outbox_count": 0
"mails_sent": 0
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"response_ok": true,
"case_address": "case=EXT-100@modes.example"
}
},
@ -262,11 +329,10 @@
"text": "more"
},
"expect": {
"response_status": "reported",
"master_received": {
"case.message": 1
},
"outbox_count": 0
"mails_sent": 0
}
},
{
@ -276,16 +342,17 @@
"body": "We are on it."
},
"expect": {
"command_ok": true,
"outbox_count": 1
"response_ok": true,
"mails_sent": 1
}
}
]
},
{
"id": "s-002",
"role": "slave",
"setup": "slave",
"title": "principle 10 refuses a master send",
"scenario": "completed",
"steps": [
{
"mail": {
@ -295,7 +362,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"master": {
@ -304,31 +374,43 @@
"body": "x"
},
"expect": {
"command_ok": false,
"command_reason": "unauthenticated-sender",
"outbox_count": 0
"response_ok": false,
"response_reason": "unauthenticated-sender",
"mails_sent": 0
}
}
]
},
{
"id": "s-003",
"role": "slave",
"setup": "slave",
"title": "transfer blocked",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"master_received": {
"case.intake": 1
}
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"master_received": {
"handoff.requested": 1,
"handoff.status": 1
},
"relayed": [
"refused"
],
"mode_received": 0
}
}
@ -336,14 +418,8 @@
},
{
"id": "s-004",
"role": "slave",
"setup": "slave-webhook",
"title": "transfer to the primed webhook; statuses relayed",
"config": {
"transfers": {
"webhook": "{callback}",
"peer": "callback-1"
}
},
"scenario": "completed",
"steps": [
{
@ -353,7 +429,10 @@
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"mode_received": 1
}
@ -366,27 +445,21 @@
"running",
"completed"
],
"outbox_count": 0
"mails_sent": 0
}
},
{
"advance": 1000000,
"expect": {
"timed_out": 0
"mode_received": 1
}
}
]
},
{
"id": "s-005",
"role": "slave",
"setup": "slave-webhook",
"title": "the master owns the timeout",
"config": {
"transfers": {
"webhook": "{callback}",
"peer": "callback-1"
}
},
"scenario": "silent",
"steps": [
{
@ -396,7 +469,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"mode": "run",
@ -404,13 +480,19 @@
"relayed": []
}
},
{
"advance": 1000000,
"expect": {
"mode_received": 1
}
},
{
"master": {
"command": "cancel",
"handoff_id": "{handoff_id}"
},
"expect": {
"command_ok": true,
"response_ok": true,
"mode_received_last": "handoff.cancel"
}
},
@ -425,9 +507,10 @@
]
},
{
"id": "a-001",
"role": "slave",
"title": "unsigned master command refused",
"id": "s-006",
"setup": "slave",
"title": "over the cap a master send is held",
"scenario": "completed",
"steps": [
{
"mail": {
@ -436,7 +519,55 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "one"
},
"expect": {
"response_ok": true,
"mails_sent": 1
}
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "two"
},
"expect": {
"response_ok": false,
"response_reason": "send-cap-reached",
"response_held": true,
"mails_sent": 1
}
}
],
"max_sent": 1
},
{
"id": "a-001",
"setup": "slave",
"title": "unsigned master command refused",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
}
},
{
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -452,23 +583,21 @@
"expect": {
"http_status": 401,
"error": "unauthenticated-peer",
"outbox_count": 0
"mails_sent": 0
}
}
]
},
{
"id": "a-002",
"role": "slave",
"setup": "slave",
"title": "a mode cannot assign case ids",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
},
"expect": {
"response_status": "intake-reported"
}
},
{
@ -487,7 +616,10 @@
}
},
{
"pump": true,
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"case_address": "case=EXT-100@modes.example"
}
@ -496,8 +628,9 @@
},
{
"id": "a-003",
"role": "slave",
"setup": "slave",
"title": "webhook to a peer email has no key for",
"scenario": "completed",
"steps": [
{
"mail": {
@ -506,7 +639,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -530,8 +666,9 @@
},
{
"id": "a-004",
"role": "slave",
"setup": "slave",
"title": "replayed master command",
"scenario": "completed",
"steps": [
{
"mail": {
@ -540,7 +677,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -557,15 +697,16 @@
"expect": {
"http_status": 401,
"error": "replayed",
"outbox_count": 0
"mails_sent": 0
}
}
]
},
{
"id": "a-005",
"role": "slave",
"setup": "slave",
"title": "master command signed with the wrong key",
"scenario": "completed",
"steps": [
{
"mail": {
@ -574,7 +715,10 @@
}
},
{
"pump": true
"master": {
"command": "assign",
"resource_id": "EXT-100"
}
},
{
"inject": {
@ -591,7 +735,7 @@
"expect": {
"http_status": 401,
"error": "unauthenticated-peer",
"outbox_count": 0
"mails_sent": 0
}
},
{
@ -607,14 +751,14 @@
},
"expect": {
"http_status": 200,
"outbox_count": 1
"mails_sent": 1
}
}
]
},
{
"id": "a-006",
"role": "master",
"setup": "master",
"title": "statuses only from the handoff's peer, at its URL",
"scenario": "silent",
"steps": [
@ -624,7 +768,7 @@
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"outbox_count": 1
"mails_sent": 1
}
},
{
@ -634,6 +778,7 @@
"message": {
"type": "handoff.status",
"case": "current",
"handoff_id": "{handoff_id}",
"seq": 1,
"status": "completed"
}
@ -651,6 +796,7 @@
"message": {
"type": "handoff.status",
"case": "current",
"handoff_id": "{handoff_id}",
"seq": 1,
"status": "completed"
}
@ -658,7 +804,7 @@
"expect": {
"http_status": 404,
"error": "unknown-handoff",
"outbox_count": 1
"mails_sent": 1
}
},
{
@ -668,6 +814,7 @@
"message": {
"type": "handoff.status",
"case": "current",
"handoff_id": "{handoff_id}",
"seq": 1,
"status": "scheduled",
"at": "x"
@ -681,8 +828,9 @@
},
{
"id": "a-007",
"role": "master",
"setup": "master",
"title": "only the gateway posts events",
"scenario": "completed",
"steps": [
{
"inject": {

View file

@ -0,0 +1,120 @@
{
"description": "Callback stub scenarios (CONTRACT.md section 8). Each is the sequence of handoff.status messages the stub sends, one per step. A step {'wait_for_details': [fields]} sends 'negotiating' with needs = the missing fields and waits until handoff.update supplies them; it is skipped when the handoff.request already carried them. After handoff.cancel the stub sends 'cancelled' next. 'silent' never answers.",
"default": "completed",
"scenarios": {
"completed": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "running"
},
{
"status": "completed"
}
],
"negotiate": [
{
"wait_for_details": [
"phone",
"time"
]
},
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "running"
},
{
"status": "completed"
}
],
"no-answer-retry": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "attempted",
"outcome": "no-answer"
},
{
"status": "scheduled",
"at": "2026-10-20T14:00:00Z"
},
{
"status": "running"
},
{
"status": "completed"
}
],
"no-answer-fail": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "attempted",
"outcome": "no-answer"
},
{
"status": "scheduled",
"at": "2026-10-20T14:00:00Z"
},
{
"status": "attempted",
"outcome": "no-answer"
},
{
"status": "failed",
"detail": "two attempts, no answer"
}
],
"call-failed": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "running"
},
{
"status": "failed",
"detail": "line dropped"
}
],
"expired": [
{
"status": "scheduled",
"at": "2026-10-20T10:00:00Z"
},
{
"status": "expired",
"detail": "slot passed"
}
],
"unavailable": [
{
"status": "refused",
"reason": "mode-unavailable"
}
],
"declined": [
{
"status": "refused",
"reason": "declined"
}
],
"out-of-hours": [
{
"status": "refused",
"reason": "out-of-hours"
}
],
"silent": []
}
}

View file

@ -52,11 +52,11 @@ class Harness:
def __init__(self, statute_file, roles: dict, scenario: str = "completed",
auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None,
sign_events: bool = False):
sign_events: bool = False, keys: Optional[dict] = None):
self.resolver = Resolver.with_statute_files([statute_file])
[self.domain] = self.resolver.pinned
self.clock = FakeClock()
self.keys = {peer: secrets.token_bytes(32) for peer in ("callback-1", "cms-1", "gateway")}
self.keys = keys or {peer: secrets.token_bytes(32) for peer in ("callback-1", "cms-1", "gateway")}
self.callback = CallbackStub({"email": self.keys["callback-1"]}, default=scenario)
self._servers = []
cb_url = self.callback_url = self._serve(self.callback.server()) + "/handoffs"

View file

@ -16,16 +16,13 @@ import uuid
from collections import Counter
from dataclasses import dataclass, field
from importlib import resources
from typing import Optional
from . import _load, flow_fixtures, message_errors, message_fixtures, transitions
from . import _load, message_errors, message_fixtures, transitions
from .contract import allowed
from .harness import Harness
from .signing import AuthError, sign, signature, verify
MASTER = {"role": "master", "fields": ("phone", "time", "scenario"), "timeout": 3600}
SLAVE = {"role": "slave"}
@dataclass
class Report:
passed: int = 0
@ -107,65 +104,141 @@ def _inject(h, spec, ctx):
return e.code, json.loads(e.read().decode() or "null")
STEPS = {"mail", "mode", "advance", "master", "inject"}
EXPECT = {"event_accepted", "mode_statuses", "mode_received", "mode_received_last",
"master_received", "relayed", "mails_sent", "last_mail_contains", "response_ok",
"response_reason", "response_held", "case_address", "http_status", "error"}
def undefined_keys(flow: dict) -> list[str]:
"""Steps or expectations not defined in conformance/FLOWS.md."""
bad = []
for n, step in enumerate(flow["steps"], 1):
bad += [f"step {n}: {k}" for k in set(step) - STEPS - {"expect"}]
bad += [f"step {n}: expect {k}" for k in set(step.get("expect", {})) - EXPECT]
return bad
def _fill(value, ctx):
if isinstance(value, str):
for k, v in ctx.items():
value = value.replace("{" + k + "}", str(v))
if isinstance(v, str):
value = value.replace("{" + k + "}", v)
return value
if isinstance(value, dict):
return {k: _fill(v, ctx) for k, v in value.items()}
return value
def _statute_path():
return resources.files(__package__).joinpath("conformance/statute.json")
def run_flow(flow: dict) -> list[str]:
"""Returns the problems found; empty when the flow passes."""
with resources.as_file(_statute_path()) as statute:
if flow["role"] == "master":
roles = {"ticket": MASTER}
def _roles(setup: dict) -> dict:
"""A flow setup (peer ids only) as harness role configs (with stub URLs)."""
def url_for(v):
if isinstance(v, str) and v.startswith("<mode:"):
return "{callback}"
if isinstance(v, dict):
return {k: url_for(x) for k, x in v.items()}
return v
roles = {}
for rtype, cfg in setup["roles"].items():
cfg = url_for(dict(cfg))
if cfg["role"] == "master":
cfg["modes"] = {m: {"url": "{callback}", **e} for m, e in cfg.get("modes", {}).items()}
cfg["fields"] = tuple(cfg.get("fields", ("phone", "time")))
else:
roles = {"case": {**SLAVE, **flow.get("config", {})}}
h = Harness(statute, roles, scenario=flow.get("scenario", "completed"), auto_assign=True)
ctx, problems = {}, []
last_command = None
timed_out: list = []
cfg["master_url"] = "{master}"
roles[rtype] = cfg
return roles
def _capture(ctx, items):
for m in items:
if not isinstance(m, dict):
continue
if isinstance(m.get("case"), dict):
ctx["case"] = m["case"]
ctx["case_id"] = m["case"]["address"].split("=", 1)[-1].rsplit("@", 1)[0]
if m.get("type") in ("case.message", None) and m.get("message_ref"):
ctx["message_ref"] = m["message_ref"]
if m.get("type") == "case.intake":
ctx["intake_ref"] = m["intake_ref"]
if m.get("type") in ("handoff.request", "handoff.requested"):
ctx["handoff_id"] = m["handoff_id"]
def _inject(h, spec, ctx):
"""Send a crafted request straight to an email endpoint (FLOWS.md, inject)."""
if spec["endpoint"] == "status":
reqs = [m for m in h.callback.received if m["type"] == "handoff.request"]
url = reqs[-1]["status_url"]
if spec.get("token") == "bogus":
url = url.rsplit("/", 1)[0] + "/not-a-token"
else:
url = h.email.base_url + {"master": "/master", "events": "/mas-events"}[spec["endpoint"]]
msg = {"mas_modes_version": "0.1", "id": uuid.uuid4().hex, **_fill(spec["message"], ctx)}
if msg.get("case") == "current":
msg["case"] = ctx["case"]
body = json.dumps(msg).encode()
headers = {"Content-Type": "application/json"}
mode = spec.get("sign", "valid")
if mode != "none":
peer = spec["as"]
key = h.keys.get(peer, b"k" * 32) if mode != "wrong-key" else b"x" * 32
ts = int(time.time()) - (400 if mode == "stale" else 0)
headers.update(sign(body, peer, key, ts))
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "null")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "null")
def _master(h, cmd, ctx):
c = cmd["command"]
if c == "assign":
return h.master.assign(cmd.get("intake_ref", ctx.get("intake_ref")), cmd["resource_id"])
if c == "send":
return h.master.send(ctx["case"], cmd["in_reply_to"], cmd["body"], cmd.get("subject"))
if c == "cancel":
return h.master.cancel(ctx["case"], cmd["handoff_id"])
if c == "policy":
return h.master.policy(ctx["case"], cmd.get("autonomous_send"), cmd.get("transfers"))
raise KeyError(f"unknown master command {c!r}")
def run_flow(flow: dict, doc: Optional[dict] = None) -> list[str]:
"""Run one flow from the bundle (FLOWS.md). Returns the problems found."""
doc = doc or _load("conformance/flows.json")
bad = undefined_keys(flow)
if bad:
return [f"undefined key(s): {bad}"]
keys = {p: bytes.fromhex(k) for p, k in doc["peers"].items()}
with resources.as_file(resources.files(__package__).joinpath("conformance", doc["statute"])) as st:
h = Harness(st, _roles(doc["setups"][flow["setup"]]), scenario=flow["scenario"],
auto_assign=False, max_sent=flow.get("max_sent"), keys=keys)
ctx: dict = {}
problems = []
try:
for n, step in enumerate(flow["steps"], 1):
resp = None
seen = (len(h.callback.received), len(h.master.received))
event, response = None, None
if "mail" in step:
m = _fill(step["mail"], ctx)
d = h.mail(m["to"], m.get("text", ""), dmarc=m.get("dmarc", "pass"))
resp = d.response if isinstance(d.response, dict) else {}
event = h.mail(m["to"], m.get("text", ""), subject=m.get("subject", "Help needed"),
dmarc=m.get("dmarc", "pass"))
elif "mode" in step:
h.callback.run()
elif "advance" in step:
timed_out = h.advance(step["advance"])
elif "pump" in step:
results = h.master.pump()
resp = results[-1][1] if results else {}
elif "inject" in step:
last_command = _inject(h, step["inject"], ctx)
h.advance(step["advance"])
elif "master" in step:
cmd = _fill(step["master"], {**ctx, "callback": ""})
case = {"address": ctx["case_address"], "key": ctx["case_key"]}
if cmd["command"] == "send":
last_command = h.master.send(case, cmd["in_reply_to"], cmd["body"])
elif cmd["command"] == "cancel":
last_command = h.master.cancel(case, cmd["handoff_id"])
if isinstance(resp, dict):
if resp.get("ticket_id"):
ctx["case_id"] = resp["ticket_id"]
if isinstance(resp.get("case"), dict):
ctx["case_id"] = resp["case"]["address"].split("=", 1)[1].split("@")[0]
ctx["case_address"], ctx["case_key"] = resp["case"]["address"], resp["case"]["key"]
for k in ("message_ref", "handoff_id", "intake_ref"):
if resp.get(k):
ctx[k] = resp[k]
response = _master(h, _fill(step["master"], ctx), ctx)
elif "inject" in step:
response = _inject(h, step["inject"], ctx)
_capture(ctx, h.callback.received[seen[0]:] + h.master.received[seen[1]:])
if response is not None:
_capture(ctx, [response[1]])
for key, want in step.get("expect", {}).items():
got = _observe(h, key, resp, last_command, timed_out)
got = _observe(h, key, event, response)
if key == "master_received":
got = {t: got.get(t, 0) for t in want}
elif key == "last_mail_contains":
@ -177,36 +250,37 @@ def run_flow(flow: dict) -> list[str]:
return problems
def _observe(h, key, resp, last_command, timed_out):
if key == "response_status":
return (resp or {}).get("status")
if key == "statuses":
def _observe(h, key, event, response):
body = (response[1] if response else None) or {}
if key == "event_accepted":
return event is not None and event.status is not None and 200 <= event.status < 300
if key == "mode_statuses":
return [m["status"] for hd in h.callback.handoffs.values() for m in hd.sent]
if key == "relayed":
return [m["status"] for m in h.master.of_type("handoff.status")]
if key == "outbox_count":
return len(h.outbox())
if key == "last_mail_contains":
mails = h.outbox()
return mails[-1]["body"] if mails else None
if key == "master_received":
return dict(Counter(m["type"] for m in h.master.received))
if key == "mode_received":
return len(h.callback.received)
if key == "mode_received_last":
return h.callback.received[-1]["type"] if h.callback.received else None
if key == "timed_out":
return len(timed_out)
if key == "master_received":
return dict(Counter(m["type"] for m in h.master.received))
if key == "relayed":
return [m["status"] for m in h.master.of_type("handoff.status")]
if key == "mails_sent":
return len(h.outbox())
if key == "last_mail_contains":
mails = h.outbox()
return mails[-1]["body"] if mails else None
if key == "response_ok":
return body.get("ok")
if key == "response_reason":
return body.get("reason")
if key == "response_held":
return body.get("held")
if key == "case_address":
return (resp or {}).get("case", {}).get("address")
if key == "command_ok":
return (last_command[1] or {}).get("ok")
if key == "command_reason":
return (last_command[1] or {}).get("reason")
return (body.get("case") or {}).get("address")
if key == "http_status":
return last_command[0]
return response[0] if response else None
if key == "error":
return (last_command[1] or {}).get("error")
return body.get("error")
raise KeyError(f"unknown expectation {key!r}")
@ -214,8 +288,9 @@ def run_mode_corpus() -> Report:
report = Report()
check_messages(report)
check_auth(report)
for flow in flow_fixtures():
problems = run_flow(flow)
doc = _load("conformance/flows.json")
for flow in doc["flows"]:
problems = run_flow(flow, doc)
if problems:
report.failures.append((flow["id"], "; ".join(problems)))
else:

View file

@ -18,32 +18,32 @@ import threading
from dataclasses import dataclass, field
from typing import Optional
from .. import message_errors
from .. import _load, message_errors
from ..contract import TERMINAL, Idempotent, json_server, message, post, refuse
AT = "2026-10-20T10:00:00Z"
NEEDED = ("phone", "time")
WAIT = "wait-for-details" # negotiation: block until handoff.update supplies NEEDED
WAIT = "wait-for-details" # negotiation: block until handoff.update supplies the needed fields
# Each step is (status, extra fields), or WAIT.
SCENARIOS: dict[str, list] = {
"completed": [("scheduled", {"at": AT}), ("running", {}), ("completed", {})],
"negotiate": [WAIT, ("scheduled", {"at": AT}), ("running", {}), ("completed", {})],
"no-answer-retry": [("scheduled", {"at": AT}), ("attempted", {"outcome": "no-answer"}),
("scheduled", {"at": "2026-10-20T14:00:00Z"}), ("running", {}),
("completed", {})],
"no-answer-fail": [("scheduled", {"at": AT}), ("attempted", {"outcome": "no-answer"}),
("scheduled", {"at": "2026-10-20T14:00:00Z"}),
("attempted", {"outcome": "no-answer"}),
("failed", {"detail": "two attempts, no answer"})],
"call-failed": [("scheduled", {"at": AT}), ("running", {}),
("failed", {"detail": "line dropped"})],
"expired": [("scheduled", {"at": AT}), ("expired", {"detail": "slot passed"})],
"unavailable": [("refused", {"reason": "mode-unavailable"})],
"declined": [("refused", {"reason": "declined"})],
"out-of-hours": [("refused", {"reason": "out-of-hours"})],
"silent": [], # never answers: the timeout owner has to act
}
def _scenarios() -> tuple[dict, tuple]:
"""The scenarios from the bundle (conformance/scenarios.json), as steps of
(status, extra fields) or WAIT."""
doc = _load("conformance/scenarios.json")
needed = ()
out = {}
for name, steps in doc["scenarios"].items():
script = []
for step in steps:
if "wait_for_details" in step:
needed = tuple(step["wait_for_details"])
script.append(WAIT)
else:
extra = {k: v for k, v in step.items() if k != "status"}
script.append((step["status"], extra))
out[name] = script
return out, needed
SCENARIOS, NEEDED = _scenarios()
@dataclass

View file

@ -22,7 +22,7 @@ packages = ["mas_core", "mas_conformance", "mas_cli", "mas_gateway", "mas_modes"
[tool.setuptools.package-data]
mas_core = ["schema/*.json"]
mas_conformance = ["*.json", "statutes/*.json"]
mas_modes = ["schema/*.json", "conformance/*.json"]
mas_modes = ["schema/*.json", "conformance/*.json", "conformance/*.md"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -7,18 +7,30 @@ import pytest
from mas_core.discovery import Response, discover
from mas_gateway.core import Resolver, build_event, message_meta_from_rfc822
from mas_modes import event_errors
from mas_modes.runner import run_mode_corpus
from mas_modes import _load
from mas_modes.runner import EXPECT, STEPS, run_mode_corpus, undefined_keys
ROOT = Path(__file__).resolve().parents[1]
BUNDLE = ROOT / "mas-modes-v0.1-spec"
VENDORED = ROOT / "mas_modes"
@pytest.mark.parametrize("rel", sorted(str(p.relative_to(BUNDLE)) for p in BUNDLE.glob("*/*.json")))
@pytest.mark.parametrize("rel", sorted(str(p.relative_to(BUNDLE)) for p in BUNDLE.glob("*/*.*")))
def test_vendored_copy_matches_bundle(rel):
assert (BUNDLE / rel).read_bytes() == (VENDORED / rel).read_bytes()
def test_flows_are_self_describing():
doc = _load("conformance/flows.json")
scenarios = _load("conformance/scenarios.json")["scenarios"]
for flow in doc["flows"]:
assert undefined_keys(flow) == [], flow["id"]
assert flow["setup"] in doc["setups"] and flow["scenario"] in scenarios, flow["id"]
text = (BUNDLE / "conformance/FLOWS.md").read_text()
for key in STEPS | EXPECT:
assert f"`{key}`" in text or f'"{key}"' in text, f"{key} is not defined in FLOWS.md"
def test_mode_corpus():
rep = run_mode_corpus()
assert rep.ok, rep.failures

View file

@ -4,7 +4,7 @@ type: workplan
title: "Mode contract flows independent of the Python harness"
domain: communication
repo: mail-address-semantics
status: proposed
status: finished
owner: claude
topic_slug: coulomb-social
created: "2026-10-19"
@ -27,7 +27,7 @@ not run the flows from the bundle alone.
```task
id: MAS-WP-0013-T01
status: todo
status: done
priority: medium
state_hub_task_id: "95fe186c-d0af-5755-a78f-065e7da7afd1"
```
@ -41,7 +41,7 @@ Remove any expectation that only the Python harness can observe.
```task
id: MAS-WP-0013-T02
status: todo
status: done
priority: medium
state_hub_task_id: "2e71569f-739a-513a-970f-0a0c867604d6"
```
@ -49,3 +49,33 @@ state_hub_task_id: "2e71569f-739a-513a-970f-0a0c867604d6"
Update `mas_modes.runner` and the stubs to read everything from the flow.
Keep `mas test` green. Add a fixture-level check that the bundle's flows
do not use undefined keys.
## Outcome
- **`conformance/FLOWS.md`** defines the format: how to set up a run (email
side, callback stub `callback-1`, master stub `cms-1`, controllable
clock, optional send cap), the five step types, captures and the 14
expectation keys. All of it is observable at the contract boundary:
messages the stubs receive, command responses, and sent mail.
- **`flows.json`** carries everything a run needs:
- `setups` with role configs by peer id (no URLs; `<mode:ID>` for a
primed webhook);
- fixed public test keys in `peers`, plus `statute`;
- per flow, a `scenario` and an optional `max_sent`;
- explicit `assign` steps with `EXT-100` instead of the stub's
auto-numbering.
- **Removed harness-only observables:** the ticket service's response
strings (now `event_accepted`, the 2xx) and `tick()`'s return value (now
`mode_received`, `mode_received_last` and `mails_sent`).
- **`conformance/scenarios.json`:** the callback scenarios as data. The
Python stub loads them from there.
- **CONTRACT.md §5** now defines the `case.assign` response, which flows
capture from.
- **New flow** `s-006` (a held send over the cap): 21 flows in all.
- **Runner** (`mas_modes.runner`) reads only the bundle and rejects
undefined keys. `tests/test_modes_conformance.py` checks that every flow
uses defined keys, existing setups and scenarios, and that every key is
documented in FLOWS.md. The drift test now also covers the vendored
FLOWS.md.
- **Checks.** A mutation check over 5 expectations, plus an undefined key,
was detected every time. `mas test`: mode corpus 195/195.