MAS-WP-0010 finished: email slave role, stub outside master, contract flows, SCP-8

- EmailMode slave role: master-issued case ids, events to the master,
  sends only on instruction or autonomous policy, transfers blocked or to
  a primed webhook, timeouts left to the master; principle 10 refusals
  reported back.
- conformance/flows.json (13 flows) + mas_modes.runner; mas test runs the
  mode corpus (172/172).
- SCP-8 / v0.2 section 7.1: !MODE requests a handoff defined by the mode
  contract. docs/reuse-surface.md.
- Deploy image copies mas_modes (ticket service imports its 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
This commit is contained in:
tegwick 2026-10-19 11:03:16 +02:00
parent e91cd01a02
commit 87be0d1da3
16 changed files with 1396 additions and 17 deletions

View file

@ -15,9 +15,12 @@ reference experiment (see `INTENT.md` and `workplans/MAS-WP-0002-*`).
| `mas_conformance/` | Shared corpus: spec fixtures (vendored, drift-tested), property cases, statute cases |
| `mas_cli/` | `mas parse / explain / discover / compose / lint-statute / test` |
| `mas_gateway/` | Generic inbound mail → MAS event → HTTP (`pipe` for MTAs, `webhook` for providers) |
| `mas_modes/` | Mode contract v0.1: email side as master or slave (`email_mode`), callback stub, stub outside master, flow runner |
| `mas-modes-v0.1-spec/` | Mode contract bundle: `CONTRACT.md`, schemas (contract + gateway event), conformance flows |
| `examples/statute/` | Static `/.well-known/mas` for `great-service.example` + tiny server |
| `examples/ticket_service/` | Reference app proving create / correlate / closed / unknown |
| `docs/experiment-review.md` | Milestone 9 findings |
| `docs/reuse-surface.md` | What a multimodal framework can depend on |
## Quick start

View file

@ -41,13 +41,15 @@ delivery stays external.
tested against a real Postfix by `experiments/postfix-probe/` and
`tests/test_postfix_profile.py` (needs Docker). Exim: notes only
(`integrations/exim/NOTES.md`).
- **Mode reference implementation** (new; INTENT.md, *Mode reference
implementation*). A language-neutral mode contract (handoffs, statuses,
timeouts, master/slave roles, outside-master commands) with JSON schemas
and fixtures. The email side implements both roles: master per resource
type, or slave to an outside master that issues case ids. Test stubs: a
scripted callback mode and a fake outside master. Email is the only real
channel.
- **Mode reference implementation** (INTENT.md, *Mode reference
implementation*). `mas-modes-v0.1-spec/` is a language-neutral contract
(handoffs, statuses, timeouts, master/slave roles, outside-master
commands) with JSON schemas, including the gateway event, and
conformance flows. `mas_modes/` holds the email side in both roles
(master per resource type over the ticket service, or slave to an
outside master that issues case ids), a scripted callback stub, a stub
outside master and the flow runner. Email is the only real channel. See
`docs/reuse-surface.md`.
- **Live test environment.** `deploy/railiance/`: the MX for
`mas.coulomb.social`, with replies through Scaleway TEM capped at 300.
It is temporary; MAS-WP-0007 retires it.
@ -76,15 +78,16 @@ delivery stays external.
- Status: active. The reference experiment (MAS-WP-0002) passed its
decision gate on 2026-10-18; the findings are in
`docs/experiment-review.md`.
- Finished: MAS-WP-0001 to 0006 and 0008. Postfix profile, v0.1.1 errata,
- Finished: MAS-WP-0001 to 0006, 0008 and 0010. Postfix profile, v0.1.1 errata,
v0.2 draft, address composer, and v0.2 conformance cases.
- Open: MAS-WP-0007 (retire the live MX; backlog, unblocked) and
MAS-WP-0009 (declare v0.2 final; waits for a second implementation or
deployment).
- v0.2 stays a draft (founder, 2026-10-19).
- P2 is decided: `!MODE` requests a handoff, defined by the mode contract
(founder, 2026-10-19). The mode contract and stubs are not started yet;
they need a workplan.
- P2 is decided: `!MODE` requests a handoff (founder, 2026-10-19; SCP-8,
v0.2 §7.1). The mode contract v0.1 draft and the reference
implementation are done (MAS-WP-0010); `mas test` runs the contract
corpus.
- The live `mas-mx` pod runs an image built before the profile refactor.
- Tests: `.venv/bin/pytest` runs 199 tests, offline apart from loopback
fixtures and the Docker probe.

View file

@ -6,6 +6,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
RUN useradd -r -s /usr/sbin/nologin mas
COPY mas_core /app/mas_core
COPY mas_gateway /app/mas_gateway
COPY mas_modes /app/mas_modes
COPY examples /app/examples
COPY experiments/postfix-probe/capture.py /app/capture.py
COPY integrations /app/integrations

49
docs/reuse-surface.md Normal file
View file

@ -0,0 +1,49 @@
# Reuse surface
This repository is the email building block for a multimodal communication
framework that is yet to be designed (INTENT.md, *Mode reference
implementation*). This page says what such a framework can depend on, and
how stable each part is.
## Depend on these
| Part | Where | Stability |
|---|---|---|
| MAS address spec | `mas-v0.1-spec/` (v0.1.1), `mas-v0.2-spec/` (draft) | v0.1.1 stable. v0.2 draft until a second implementation passes its corpus (MAS-WP-0009) |
| Mode contract | `mas-modes-v0.1-spec/` | v0.1 draft. Changes are versioned; messages carry `mas_modes_version` |
| Gateway event | `mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json` | v0.1. Events carry `mas_event_version` |
| Conformance corpora | the bundles' `conformance/` | Normative for their version. Consume them unchanged |
| `mas_core` | parse (no I/O), discover (exact host), resolve (pure), generate / `for_destination` | Python reference library |
| `mas_gateway` | MTA `pipe` and provider `webhook` → event | Reference adapter. The event is the interface, not the code |
| Postfix profile | `integrations/postfix/` | Tested against Postfix 3.7 (`tests/test_postfix_profile.py`) |
## Integrating as an outside master
Configure the resource type as `slave` (`mas_modes.email_mode.RoleConfig`)
and point `master_url` at your system. You then:
- receive `case.intake` and `case.message`, each carrying a gateway event;
- answer `case.assign` with your own case id; email adopts it as
`TYPE=ID@domain`;
- send replies with `mail.send` (`in_reply_to` a recorded message);
principle 10 can refuse them;
- decide per case on `autonomous_send` and `transfers` (`block`, or your
mode webhook);
- own every timeout; end a handoff with `handoff.cancel`.
## Integrating a mode
Implement the mode endpoint in CONTRACT.md: accept `handoff.request`,
`handoff.update` and `handoff.cancel`, and post `handoff.status` to
`status_url` with an increasing `seq`. Never send `timed-out`.
`mas_modes/stubs/callback.py` is a complete example.
## Examples only, not products
- `examples/ticket_service/`: the reference ticket service, used as the
store for email-mastered cases.
- `mas_modes/stubs/`: the callback stub and the stub outside master.
They never contact a person or a service.
- `mas_modes/email_mode.py`: the reference email side. It is
single-process, with SQLite and no authentication on its HTTP endpoints,
so it is fit for loopback and tests. The contract is the interface.

View file

@ -299,6 +299,34 @@ case-insensitive by design.
---
## SCP-8: `!MODE` requests a handoff, defined by the mode contract
**Section:** §7 Interaction semantics.
**Evidence:** phase 1 showed that `!callback` expresses intent, but that a
callback still needs a number and a time, so it starts a negotiation. The
founder decided on 2026-10-19 (INTENT.md, phase 2) that `!MODE` requests a
*handoff* from email to `MODE`. MAS-WP-0010 implements this as a separate
contract (`mas-modes-v0.1-spec/`), with email as master or as slave per
resource type.
**Proposed text (§7, appended):**
> A receiver that acts on an interaction request treats it as a request
> to hand the conversation over to `MODE`. What can happen to that request
> (negotiation, scheduling, completion, failure, refusal, timeout) and
> who owns the conversation meanwhile is defined by the MAS mode
> contract, which is versioned separately (`mas-modes-v0.1-spec/`).
> Email remains the durable thread when it is the master. The handoff
> stays a request: refusal is a normal outcome, and the address grants
> nothing.
**Impact:** informative for parsers and generators: there is no change to
the grammar, the statute or the conformance corpus. Receivers that
implement handoffs get a contract to conform to.
---
## Lower-priority gaps (no proposal yet)
Recorded in the review, not urgent:

View file

@ -0,0 +1,22 @@
# MAS mode contract v0.1 (draft): specification bundle
How email hands a case to another communication mode, and how email works
as one element of a control flow it does not own. It is versioned
separately from the MAS address specification (`mas-v0.2-spec/`, §7).
## Contents
- `CONTRACT.md`: normative text (roles, transport, status machine,
message types, principle 10)
- `schema/mas-modes-v0.1.schema.json`: every contract message
- `schema/mas-event-v0.1.schema.json`: the normalized gateway event
(`mas-gateway` → application, and `case.intake` / `case.message`
payloads)
- `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`
- `conformance/statute.json`: the statute the flows run under
The reference email side, the callback stub and the stub outside master are
in `mas_modes/`. `mas test` runs this bundle against them.

View file

@ -0,0 +1,426 @@
{
"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. 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'.",
"flows": [
{
"id": "m-001",
"role": "master",
"title": "callback completes",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "created"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"scheduled",
"running",
"completed"
],
"last_mail_contains": "has taken place"
}
}
]
},
{
"id": "m-002",
"role": "master",
"title": "negotiation over email",
"scenario": "negotiate",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Please call me."
},
"expect": {
"response_status": "created"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"negotiating"
],
"last_mail_contains": "Phone: ..."
}
},
{
"mail": {
"to": "ticket={case_id}",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "correlated"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"negotiating",
"scheduled",
"running",
"completed"
],
"last_mail_contains": "has taken place"
}
}
]
},
{
"id": "m-003",
"role": "master",
"title": "no answer, retried, completed",
"scenario": "no-answer-retry",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"scheduled",
"attempted",
"scheduled",
"running",
"completed"
]
}
}
]
},
{
"id": "m-004",
"role": "master",
"title": "no answer twice, failed",
"scenario": "no-answer-fail",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"scheduled",
"attempted",
"scheduled",
"attempted",
"failed"
],
"last_mail_contains": "did not succeed"
}
}
]
},
{
"id": "m-005",
"role": "master",
"title": "refused out of hours",
"scenario": "out-of-hours",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"refused"
],
"last_mail_contains": "out-of-hours"
}
}
]
},
{
"id": "m-006",
"role": "master",
"title": "email owns the timeout",
"scenario": "silent",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": []
}
},
{
"advance": 3599,
"expect": {
"timed_out": 0
}
},
{
"advance": 2,
"expect": {
"timed_out": 1,
"mode_received_last": "handoff.cancel",
"last_mail_contains": "could not arrange"
}
}
]
},
{
"id": "m-007",
"role": "master",
"title": "mode not enabled by the statute",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "ticket!chat",
"text": "hi"
},
"expect": {
"response_status": "created",
"mode_received": 0,
"outbox_count": 1
}
}
]
},
{
"id": "m-008",
"role": "master",
"title": "unauthenticated sender gets no mail",
"scenario": "negotiate",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "call me",
"dmarc": "fail"
},
"expect": {
"response_status": "created"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"negotiating"
],
"outbox_count": 0
}
}
]
},
{
"id": "s-001",
"role": "slave",
"title": "master issues the id; email sends only on instruction",
"steps": [
{
"mail": {
"to": "case",
"text": "My order is late"
},
"expect": {
"response_status": "intake-reported",
"master_received": {
"case.intake": 1
},
"outbox_count": 0
}
},
{
"pump": true,
"expect": {
"case_address": "case=EXT-100@modes.example"
}
},
{
"mail": {
"to": "case={case_id}",
"text": "more"
},
"expect": {
"response_status": "reported",
"master_received": {
"case.message": 1
},
"outbox_count": 0
}
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "We are on it."
},
"expect": {
"command_ok": true,
"outbox_count": 1
}
}
]
},
{
"id": "s-002",
"role": "slave",
"title": "principle 10 refuses a master send",
"steps": [
{
"mail": {
"to": "case",
"text": "hi",
"dmarc": "fail"
}
},
{
"pump": true
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "x"
},
"expect": {
"command_ok": false,
"command_reason": "unauthenticated-sender",
"outbox_count": 0
}
}
]
},
{
"id": "s-003",
"role": "slave",
"title": "transfer blocked",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"pump": true,
"expect": {
"master_received": {
"handoff.requested": 1,
"handoff.status": 1
},
"mode_received": 0
}
}
]
},
{
"id": "s-004",
"role": "slave",
"title": "transfer to the primed webhook; statuses relayed",
"config": {
"transfers": {
"webhook": "{callback}"
}
},
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"pump": true,
"expect": {
"mode_received": 1
}
},
{
"mode": "run",
"expect": {
"relayed": [
"scheduled",
"running",
"completed"
],
"outbox_count": 0
}
},
{
"advance": 1000000,
"expect": {
"timed_out": 0
}
}
]
},
{
"id": "s-005",
"role": "slave",
"title": "the master owns the timeout",
"config": {
"transfers": {
"webhook": "{callback}"
}
},
"scenario": "silent",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"pump": true
},
{
"mode": "run",
"expect": {
"relayed": []
}
},
{
"master": {
"command": "cancel",
"handoff_id": "{handoff_id}"
},
"expect": {
"command_ok": true,
"mode_received_last": "handoff.cancel"
}
},
{
"mode": "run",
"expect": {
"relayed": [
"cancelled"
]
}
}
]
}
]
}

View file

@ -24,6 +24,10 @@ New features, opt-in per statute with `"mas_version": "0.2"`
unquoted form. Receivers treat them identically; generators emit the
unquoted form; canonical parsers still reject the quoted form (§4).
Fixture p1-040 note updated.
- **Handoffs (SCP-8), §7.1.** `!MODE` requests a handoff whose behavior
the separate MAS mode contract defines (`mas-modes-v0.1-spec/`,
MAS-WP-0010). It is informative for parsers and generators, with no
grammar or statute change.
- **Conformance (MAS-WP-0008).** `conformance/statute-aware.json` and
the `MAS-V0.2-STATUTE-AWARE` surface cover the stand-in table, the
`resource_id_case` equivalence and destination-transform refusals.

View file

@ -69,6 +69,10 @@ case names one of the file's inline statutes, whose `namespace` is the host.
`reason` is informative, and refusal codes are not normative.
`allow_lossy` marks an explicit request for a drop transform.
Handoff behavior (§7.1) is not tested here. Its conformance surfaces
(`MAS-MODES-EMAIL-MASTER`, `MAS-MODES-EMAIL-SLAVE`) and fixtures are in
`mas-modes-v0.1-spec/`.
## 6. Discovery failure
Failure to retrieve or validate a statute:

View file

@ -310,6 +310,18 @@ MUST be a resource type the statute declares or inherits. Without such a
registration a target has no intake semantics, even if it equals a declared
resource type.
### 7.1 Handoffs (v0.2)
A receiver that acts on an interaction request treats it as a request to
hand the conversation over to `MODE`. What can happen to that request
(negotiation, scheduling, completion, failure, refusal, timeout), and who
owns the conversation meanwhile, is defined by the MAS mode contract. The
contract is versioned separately (`mas-modes-v0.1-spec/`, draft). Email
remains the durable thread when it is the master. The handoff stays a
request: refusal is a normal outcome, and the address grants nothing.
This section changes no grammar, statute field or address fixture.
## 8. Address equivalence
MAS does not impose case folding, dot folding, alias folding, or Unicode

View file

@ -242,7 +242,12 @@ def cmd_test(args):
print(f"v0.2 statute-aware corpus: {aware.passed}/{aware.passed + len(aware.failures)} passed")
for fid, subject, why in aware.failures:
print(f" FAIL {fid} {subject!r}: {why}")
return 0 if rep.ok and not st_fail and aware.ok else 1
from mas_modes.runner import run_mode_corpus
modes = run_mode_corpus()
print(f"mode contract v0.1 corpus: {modes.passed}/{modes.passed + len(modes.failures)} passed")
for fid, why in modes.failures:
print(f" FAIL {fid}: {why}")
return 0 if rep.ok and not st_fail and aware.ok and modes.ok else 1
def build_parser():

View file

@ -0,0 +1,426 @@
{
"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. 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'.",
"flows": [
{
"id": "m-001",
"role": "master",
"title": "callback completes",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "created"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"scheduled",
"running",
"completed"
],
"last_mail_contains": "has taken place"
}
}
]
},
{
"id": "m-002",
"role": "master",
"title": "negotiation over email",
"scenario": "negotiate",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Please call me."
},
"expect": {
"response_status": "created"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"negotiating"
],
"last_mail_contains": "Phone: ..."
}
},
{
"mail": {
"to": "ticket={case_id}",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"response_status": "correlated"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"negotiating",
"scheduled",
"running",
"completed"
],
"last_mail_contains": "has taken place"
}
}
]
},
{
"id": "m-003",
"role": "master",
"title": "no answer, retried, completed",
"scenario": "no-answer-retry",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"scheduled",
"attempted",
"scheduled",
"running",
"completed"
]
}
}
]
},
{
"id": "m-004",
"role": "master",
"title": "no answer twice, failed",
"scenario": "no-answer-fail",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"scheduled",
"attempted",
"scheduled",
"attempted",
"failed"
],
"last_mail_contains": "did not succeed"
}
}
]
},
{
"id": "m-005",
"role": "master",
"title": "refused out of hours",
"scenario": "out-of-hours",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"refused"
],
"last_mail_contains": "out-of-hours"
}
}
]
},
{
"id": "m-006",
"role": "master",
"title": "email owns the timeout",
"scenario": "silent",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"mode": "run",
"expect": {
"statuses": []
}
},
{
"advance": 3599,
"expect": {
"timed_out": 0
}
},
{
"advance": 2,
"expect": {
"timed_out": 1,
"mode_received_last": "handoff.cancel",
"last_mail_contains": "could not arrange"
}
}
]
},
{
"id": "m-007",
"role": "master",
"title": "mode not enabled by the statute",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "ticket!chat",
"text": "hi"
},
"expect": {
"response_status": "created",
"mode_received": 0,
"outbox_count": 1
}
}
]
},
{
"id": "m-008",
"role": "master",
"title": "unauthenticated sender gets no mail",
"scenario": "negotiate",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "call me",
"dmarc": "fail"
},
"expect": {
"response_status": "created"
}
},
{
"mode": "run",
"expect": {
"statuses": [
"negotiating"
],
"outbox_count": 0
}
}
]
},
{
"id": "s-001",
"role": "slave",
"title": "master issues the id; email sends only on instruction",
"steps": [
{
"mail": {
"to": "case",
"text": "My order is late"
},
"expect": {
"response_status": "intake-reported",
"master_received": {
"case.intake": 1
},
"outbox_count": 0
}
},
{
"pump": true,
"expect": {
"case_address": "case=EXT-100@modes.example"
}
},
{
"mail": {
"to": "case={case_id}",
"text": "more"
},
"expect": {
"response_status": "reported",
"master_received": {
"case.message": 1
},
"outbox_count": 0
}
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "We are on it."
},
"expect": {
"command_ok": true,
"outbox_count": 1
}
}
]
},
{
"id": "s-002",
"role": "slave",
"title": "principle 10 refuses a master send",
"steps": [
{
"mail": {
"to": "case",
"text": "hi",
"dmarc": "fail"
}
},
{
"pump": true
},
{
"master": {
"command": "send",
"in_reply_to": "{message_ref}",
"body": "x"
},
"expect": {
"command_ok": false,
"command_reason": "unauthenticated-sender",
"outbox_count": 0
}
}
]
},
{
"id": "s-003",
"role": "slave",
"title": "transfer blocked",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"pump": true,
"expect": {
"master_received": {
"handoff.requested": 1,
"handoff.status": 1
},
"mode_received": 0
}
}
]
},
{
"id": "s-004",
"role": "slave",
"title": "transfer to the primed webhook; statuses relayed",
"config": {
"transfers": {
"webhook": "{callback}"
}
},
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"pump": true,
"expect": {
"mode_received": 1
}
},
{
"mode": "run",
"expect": {
"relayed": [
"scheduled",
"running",
"completed"
],
"outbox_count": 0
}
},
{
"advance": 1000000,
"expect": {
"timed_out": 0
}
}
]
},
{
"id": "s-005",
"role": "slave",
"title": "the master owns the timeout",
"config": {
"transfers": {
"webhook": "{callback}"
}
},
"scenario": "silent",
"steps": [
{
"mail": {
"to": "case!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
}
},
{
"pump": true
},
{
"mode": "run",
"expect": {
"relayed": []
}
},
{
"master": {
"command": "cancel",
"handoff_id": "{handoff_id}"
},
"expect": {
"command_ok": true,
"mode_received_last": "handoff.cancel"
}
},
{
"mode": "run",
"expect": {
"relayed": [
"cancelled"
]
}
}
]
}
]
}

170
mas_modes/runner.py Normal file
View file

@ -0,0 +1,170 @@
"""Run the mode-contract conformance bundle against this email side.
Message fixtures and the transition table are checked against the schema
and `contract.allowed`; flows run through `harness.Harness` with the real
gateway, the callback stub and the stub outside master. Used by `mas test`
and pytest.
"""
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass, field
from importlib import resources
from . import flow_fixtures, message_errors, message_fixtures, transitions
from .contract import allowed
from .harness import Harness
MASTER = {"role": "master", "modes": {"callback": "{callback}"},
"fields": ("phone", "time", "scenario"), "timeout": 3600}
SLAVE = {"role": "slave", "master_url": "{master}"}
@dataclass
class Report:
passed: int = 0
failures: list = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.failures
def check_messages(report: Report):
fx = message_fixtures()
for c in fx["valid"]:
errs = message_errors(c["message"])
(report.failures.append((c["case_id"], f"should be valid: {errs}")) if errs
else _ok(report))
for c in fx["invalid"]:
(report.failures.append((c["case_id"], f"should be invalid ({c['why']})"))
if not message_errors(c["message"]) else _ok(report))
t = transitions()
statuses = set(t["terminal"]) | set(t["transitions"]) - {"(new)"}
for old in [None, *sorted(statuses)]:
for new in sorted(statuses):
want = new in t["transitions"].get(old or "(new)", [])
if allowed(old, new) != want:
report.failures.append((f"transition {old}->{new}", f"expected allowed={want}"))
else:
_ok(report)
def _ok(report):
report.passed += 1
def _fill(value, ctx):
if isinstance(value, str):
for k, v in ctx.items():
value = value.replace("{" + k + "}", str(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}
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 = []
try:
for n, step in enumerate(flow["steps"], 1):
resp = 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 {}
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 "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"):
if resp.get(k):
ctx[k] = resp[k]
for key, want in step.get("expect", {}).items():
got = _observe(h, key, resp, last_command, timed_out)
if key == "master_received":
got = {t: got.get(t, 0) for t in want}
elif key == "last_mail_contains":
got, want = (want in (got or "")), True
if got != want:
problems.append(f"step {n} {key}: got {got!r}, expected {want!r}")
finally:
h.close()
return problems
def _observe(h, key, resp, last_command, timed_out):
if key == "response_status":
return (resp or {}).get("status")
if key == "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 == "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")
raise KeyError(f"unknown expectation {key!r}")
def run_mode_corpus() -> Report:
report = Report()
check_messages(report)
for flow in flow_fixtures():
problems = run_flow(flow)
if problems:
report.failures.append((flow["id"], "; ".join(problems)))
else:
report.passed += 1
return report
if __name__ == "__main__":
r = run_mode_corpus()
print(json.dumps({"passed": r.passed, "failures": r.failures}, indent=2))

View file

@ -0,0 +1,39 @@
"""Mode contract bundle (MAS-WP-0010 T02/T03/T07): vendored copies, fixtures, flows,
and the gateway event schema checked against real gateway output."""
from pathlib import Path
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
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")))
def test_vendored_copy_matches_bundle(rel):
assert (BUNDLE / rel).read_bytes() == (VENDORED / rel).read_bytes()
def test_mode_corpus():
rep = run_mode_corpus()
assert rep.ok, rep.failures
assert rep.passed >= 170
@pytest.mark.parametrize("recipient", [
"ticket!callback@great-service.example", "ticket=GS-1+comments@great-service.example",
"bernd.worsch@great-service.example", "ticket=PR42$4@great-service.example",
"x@unknown.invalid"])
def test_gateway_events_match_schema(recipient):
raw = (b"Authentication-Results: great-service.example; dmarc=pass header.from=example.net\r\n"
b"From: c@example.net\r\nTo: x@great-service.example\r\nSubject: s\r\n\r\nbody\r\n")
offline = lambda host, **kw: discover(host, transport=lambda u, t: Response(404, {}, b"")) # noqa: E731
resolver = Resolver.with_statute_files(
[ROOT / "examples/statute/great-service.example/.well-known/mas"], discover_fn=offline)
event = build_event(recipient, message_meta_from_rfc822(raw, "great-service.example"), resolver)
assert event_errors(event) == [], event

162
tests/test_modes_slave.py Normal file
View file

@ -0,0 +1,162 @@
"""Email as slave (CONTRACT.md 3.2) under a stub outside master (MAS-WP-0010 T06)."""
import pytest
from mas_modes.harness import Harness, ROOT
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
D = "modes.example"
def harness(**cfg):
return Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}", **cfg}})
@pytest.fixture
def h():
harness_ = harness()
yield harness_
harness_.close()
def intake(h, local="case", text="My order is late", **kw):
d = h.mail(local, text, **kw)
assert d.status == 202 and d.response["status"] == "intake-reported"
[(code, body)] = h.master.pump()
assert code == 200, body
return body
def test_master_issues_the_case_id(h):
d = h.mail("case", "My order is late")
[msg] = h.master.of_type("case.intake")
assert msg["intake_ref"] == d.response["intake_ref"] and msg["event"]["mas"]["intake_for"] == "case"
assert h.outbox() == [] # nothing sent before the master decides
[(code, body)] = h.master.pump()
assert body["case"] == {"address": f"case=EXT-100@{D}", "key": "case=ext-100"}
d = h.mail("case=EXT-100", "more details")
assert d.response["status"] == "reported"
[m] = h.master.of_type("case.message")
assert m["case"]["key"] == "case=ext-100" and m["event"]["message"]["text"].startswith("more details")
assert h.outbox() == [] # still silent: no autonomous sending
def test_email_never_invents_ids(h):
h.mail("case", "hi")
assert h.mail("case=EXT-999", "guess").response["status"] == "unknown-resource"
assert h.master.of_type("case.message") == []
def test_assign_refusals(h):
h.master.auto_assign = False
ref = h.mail("case", "hi").response["intake_ref"]
assert h.master.assign("nope", "X1")[0] == 404
assert h.master.assign(ref, "bad+id")[0] == 400
assert h.master.assign(ref, "X1")[0] == 200
assert h.master.assign(ref, "X2")[1]["error"] == "already-assigned"
ref2 = h.mail("case", "again").response["intake_ref"]
assert h.master.assign(ref2, "x1")[1]["error"] == "case-exists" # ids are case-insensitive here
def test_send_only_on_instruction(h):
body = intake(h)
case, mref = body["case"], body["message_ref"]
code, resp = h.master.send(case, mref, "We are looking into it.")
assert (code, resp) == (200, {"ok": True, "sent": True})
[mail] = h.outbox()
assert mail["mail_from"] == f"case=EXT-100@{D}" and mail["mail_to"] == "customer@example.net"
assert mail["subject"] == "Re: Help needed"
@pytest.mark.parametrize("kw, reason", [
({"dmarc": "fail"}, "unauthenticated-sender"),
({"dmarc": None}, "unauthenticated-sender"),
({"headers": "Auto-Submitted: auto-replied\r\n"}, "automated-sender"),
])
def test_principle_10_refuses_master_sends(h, kw, reason):
body = intake(h, **kw)
code, resp = h.master.send(body["case"], body["message_ref"], "hello")
assert code == 409 and resp["error"] == "refused" and resp["reason"] == reason
assert h.outbox() == []
def test_send_cap_and_unknown_message():
h = Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}"}}, max_sent=0)
try:
body = intake(h)
assert h.master.send(body["case"], body["message_ref"], "x")[1]["reason"] == "send-cap-reached"
assert h.master.send(body["case"], "m-unknown", "x")[1]["reason"] == "unknown-message"
finally:
h.close()
def test_autonomous_policy_acknowledges():
h = harness(autonomous_send=True)
try:
body = intake(h)
assert body["acknowledged"] is True
[ack] = h.outbox()
assert "case EXT-100" in ack["body"] and ack["mail_from"] == f"case=EXT-100@{D}"
finally:
h.close()
def test_transfer_blocked_by_default(h):
body = intake(h, local="case!callback")
[req] = h.master.of_type("handoff.requested")
assert req["forwarded"] is False and req["handoff_id"] == body["handoff_id"]
[st] = h.master.of_type("handoff.status")
assert (st["status"], st["reason"]) == ("refused", "blocked")
assert h.callback.received == [] and h.outbox() == []
def test_transfer_to_primed_webhook():
h = harness(transfers={"webhook": "{callback}"})
try:
body = intake(h, local="case!callback", text="Phone: 123\nTime: now")
assert h.master.of_type("handoff.requested")[0]["forwarded"] is True
[req] = h.callback.received
assert req["case"]["key"] == "case=ext-100" and req["details"] == {"phone": "123", "time": "now"}
h.callback.run()
relayed = h.master.of_type("handoff.status")
assert [m["status"] for m in relayed] == ["scheduled", "running", "completed"]
assert [m["seq"] for m in relayed] == [1, 2, 3]
assert h.outbox() == [] # resuming is the master's job
assert h.advance(10 ** 6) == [] # and so are timeouts
finally:
h.close()
def test_policy_from_assign_and_later_update(h):
h.master.auto_assign = False
ref = h.mail("case!callback", "Phone: 1\nTime: 2").response["intake_ref"]
code, body = h.master.command("case.assign", intake_ref=ref, resource_id="A1",
policy={"transfers": "block", "autonomous_send": False})
assert code == 200 and h.master.of_type("handoff.requested")[0]["forwarded"] is False
code, body = h.master.policy(body["case"], autonomous_send=True)
assert body["policy"] == {"autonomous_send": True, "transfers": "block"}
def test_negotiation_only_when_autonomous():
for autonomous, mails in ((False, 0), (True, 2)): # acknowledgement + question
h = harness(transfers={"webhook": "{callback}"}, autonomous_send=autonomous)
try:
h.callback.default = "negotiate"
intake(h, local="case!callback", text="call me")
h.callback.run()
assert h.master.of_type("handoff.status")[-1]["status"] == "negotiating"
assert len(h.outbox()) == mails
finally:
h.close()
def test_master_cancels_on_its_own_timeout():
h = harness(transfers={"webhook": "{callback}"}, fields=("phone", "time", "scenario"))
try:
body = intake(h, local="case!callback", text="Phone: 1\nTime: 2\nScenario: silent")
assert h.callback.run() == []
code, resp = h.master.cancel(body["case"], body["handoff_id"])
assert code == 200 and h.callback.received[-1]["type"] == "handoff.cancel"
h.callback.run()
assert h.master.of_type("handoff.status")[-1]["status"] == "cancelled"
finally:
h.close()

View file

@ -4,7 +4,7 @@ type: workplan
title: "Mode reference implementation: contract, email master/slave roles, stubs"
domain: communication
repo: mail-address-semantics
status: active
status: finished
owner: claude
topic_slug: coulomb-social
created: "2026-10-19"
@ -63,7 +63,7 @@ Review the design with the founder before T02.
```task
id: MAS-WP-0010-T02
status: progress
status: done
priority: high
state_hub_task_id: "e0af967d-15e8-50ad-8930-ad569224cddf"
```
@ -132,7 +132,7 @@ Add end-to-end tests against the callback stub for every scenario.
```task
id: MAS-WP-0010-T06
status: todo
status: done
priority: high
state_hub_task_id: "fbef3fea-c0b6-5e79-bdf9-95bbf98c9bc6"
```
@ -154,7 +154,7 @@ Add end-to-end tests: stub master + email slave + callback stub.
```task
id: MAS-WP-0010-T07
status: todo
status: done
priority: medium
state_hub_task_id: "0fcb541f-cab8-5014-aee0-4332fc7e0642"
```
@ -169,7 +169,7 @@ stubs). Update SCOPE.md.
```task
id: MAS-WP-0010-T08
status: todo
status: done
priority: medium
state_hub_task_id: "7d5dca10-184f-5b96-a280-b59dd8d77c60"
```
@ -178,3 +178,28 @@ Write a spec change proposal (SCP-8) and put it into the v0.2 draft:
§7 says `!MODE` requests a handoff whose behavior the mode contract
defines, and points to `mas-modes-v0.1-spec/`. No grammar or statute
change. Add CHANGELOG and CONFORMANCE notes.
## Outcome
- **Contract** (`mas-modes-v0.1-spec/`, draft). Reviewed by the founder on
2026-10-19: `mail.send` is replies only, and the status set is accepted.
It has a message schema, the gateway event schema (T03), the transition
table, 27 message fixtures, a statute and 13 scripted flows. One
addition during implementation: `case.assign` may carry the initial
policy, so a mode requested in the intake mail can be blocked or primed.
- **Code** (`mas_modes/`). `email_mode.EmailMode` covers both roles: the
master role runs over the ticket service through `MasterStore`, and the
slave role over its own SQLite store. `mailer` holds the principle-10
gate (now shared with the ticket service). `stubs/callback.py` has 10
scenarios, and `stubs/master.py` assigns EXT-100, EXT-101, .... There is
also `harness`, and `runner` (`mas test`: 172/172).
- **Tests.** `tests/test_modes_master.py` (17), `test_modes_slave.py`
(14), `test_modes_conformance.py` (12, including real gateway events
validated against the schema).
- **Spec.** SCP-8, plus v0.2 §7.1 "Handoffs" and notes in CHANGELOG and
CONFORMANCE.
- **Docs.** `docs/reuse-surface.md` and the bundle README.
- **Not done.** No second mode stub: callback was enough to exercise the
contract, and the contract text is mode-neutral. The reference email
side has no authentication on its HTTP endpoints; it is for loopback and
tests only, as documented.