From 87be0d1da36c20eea23b84896f7f82917cd295e0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 11:03:16 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd --- README.md | 3 + SCOPE.md | 25 +- deploy/railiance/Dockerfile | 1 + docs/reuse-surface.md | 49 ++ docs/spec-change-proposals.md | 28 ++ mas-modes-v0.1-spec/README.md | 22 + mas-modes-v0.1-spec/conformance/flows.json | 426 ++++++++++++++++++ mas-v0.2-spec/CHANGELOG.md | 4 + mas-v0.2-spec/CONFORMANCE.md | 4 + mas-v0.2-spec/SPECIFICATION.md | 12 + mas_cli/main.py | 7 +- mas_modes/conformance/flows.json | 426 ++++++++++++++++++ mas_modes/runner.py | 170 +++++++ tests/test_modes_conformance.py | 39 ++ tests/test_modes_slave.py | 162 +++++++ ...S-WP-0010-mode-reference-implementation.md | 35 +- 16 files changed, 1396 insertions(+), 17 deletions(-) create mode 100644 docs/reuse-surface.md create mode 100644 mas-modes-v0.1-spec/README.md create mode 100644 mas-modes-v0.1-spec/conformance/flows.json create mode 100644 mas_modes/conformance/flows.json create mode 100644 mas_modes/runner.py create mode 100644 tests/test_modes_conformance.py create mode 100644 tests/test_modes_slave.py diff --git a/README.md b/README.md index af8b373..0da7031 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SCOPE.md b/SCOPE.md index c117cd7..785b224 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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. diff --git a/deploy/railiance/Dockerfile b/deploy/railiance/Dockerfile index 503cd96..46ecda8 100644 --- a/deploy/railiance/Dockerfile +++ b/deploy/railiance/Dockerfile @@ -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 diff --git a/docs/reuse-surface.md b/docs/reuse-surface.md new file mode 100644 index 0000000..249fa8f --- /dev/null +++ b/docs/reuse-surface.md @@ -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. diff --git a/docs/spec-change-proposals.md b/docs/spec-change-proposals.md index 2e65c23..131a3b8 100644 --- a/docs/spec-change-proposals.md +++ b/docs/spec-change-proposals.md @@ -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: diff --git a/mas-modes-v0.1-spec/README.md b/mas-modes-v0.1-spec/README.md new file mode 100644 index 0000000..77e26c9 --- /dev/null +++ b/mas-modes-v0.1-spec/README.md @@ -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. diff --git a/mas-modes-v0.1-spec/conformance/flows.json b/mas-modes-v0.1-spec/conformance/flows.json new file mode 100644 index 0000000..7473659 --- /dev/null +++ b/mas-modes-v0.1-spec/conformance/flows.json @@ -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" + ] + } + } + ] + } + ] +} diff --git a/mas-v0.2-spec/CHANGELOG.md b/mas-v0.2-spec/CHANGELOG.md index 3a203bf..f737912 100644 --- a/mas-v0.2-spec/CHANGELOG.md +++ b/mas-v0.2-spec/CHANGELOG.md @@ -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. diff --git a/mas-v0.2-spec/CONFORMANCE.md b/mas-v0.2-spec/CONFORMANCE.md index c6b8bf5..3b67752 100644 --- a/mas-v0.2-spec/CONFORMANCE.md +++ b/mas-v0.2-spec/CONFORMANCE.md @@ -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: diff --git a/mas-v0.2-spec/SPECIFICATION.md b/mas-v0.2-spec/SPECIFICATION.md index 1910862..dd25241 100644 --- a/mas-v0.2-spec/SPECIFICATION.md +++ b/mas-v0.2-spec/SPECIFICATION.md @@ -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 diff --git a/mas_cli/main.py b/mas_cli/main.py index 661dd7b..4b88800 100644 --- a/mas_cli/main.py +++ b/mas_cli/main.py @@ -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(): diff --git a/mas_modes/conformance/flows.json b/mas_modes/conformance/flows.json new file mode 100644 index 0000000..7473659 --- /dev/null +++ b/mas_modes/conformance/flows.json @@ -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" + ] + } + } + ] + } + ] +} diff --git a/mas_modes/runner.py b/mas_modes/runner.py new file mode 100644 index 0000000..d0bb866 --- /dev/null +++ b/mas_modes/runner.py @@ -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)) diff --git a/tests/test_modes_conformance.py b/tests/test_modes_conformance.py new file mode 100644 index 0000000..d6ebc77 --- /dev/null +++ b/tests/test_modes_conformance.py @@ -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 diff --git a/tests/test_modes_slave.py b/tests/test_modes_slave.py new file mode 100644 index 0000000..cb67738 --- /dev/null +++ b/tests/test_modes_slave.py @@ -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() diff --git a/workplans/MAS-WP-0010-mode-reference-implementation.md b/workplans/MAS-WP-0010-mode-reference-implementation.md index 23830c6..0e0a811 100644 --- a/workplans/MAS-WP-0010-mode-reference-implementation.md +++ b/workplans/MAS-WP-0010-mode-reference-implementation.md @@ -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.