From e91cd01a020a30257e9f531abd865224fadf4a01 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 10:57:20 +0200 Subject: [PATCH] MAS-WP-0010 T01-T05: mode contract v0.1, schemas, callback stub, email as master mas-modes-v0.1-spec/ (contract, message + gateway-event schemas, transition table, message fixtures, statute) vendored into mas_modes. EmailMode implements the master role over the reference ticket service (MasterStore): handoff, negotiation by email, statuses in the thread, timeouts, resume by email, principle 10 shared via mas_modes.mailer. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd --- examples/ticket_service/ticket_service.py | 29 +- mas-modes-v0.1-spec/CONTRACT.md | 167 +++++ mas-modes-v0.1-spec/conformance/messages.json | 440 +++++++++++ mas-modes-v0.1-spec/conformance/statute.json | 18 + .../conformance/transitions.json | 59 ++ .../schema/mas-event-v0.1.schema.json | 281 +++++++ .../schema/mas-modes-v0.1.schema.json | 689 ++++++++++++++++++ mas_modes/__init__.py | 45 ++ mas_modes/conformance/messages.json | 440 +++++++++++ mas_modes/conformance/statute.json | 18 + mas_modes/conformance/transitions.json | 59 ++ mas_modes/contract.py | 103 +++ mas_modes/email_mode.py | 432 +++++++++++ mas_modes/harness.py | 114 +++ mas_modes/mailer.py | 82 +++ mas_modes/schema/mas-event-v0.1.schema.json | 281 +++++++ mas_modes/schema/mas-modes-v0.1.schema.json | 689 ++++++++++++++++++ mas_modes/stubs/callback.py | 154 ++++ mas_modes/stubs/master.py | 79 ++ pyproject.toml | 3 +- tests/test_modes_master.py | 146 ++++ ...S-WP-0010-mode-reference-implementation.md | 12 +- 22 files changed, 4327 insertions(+), 13 deletions(-) create mode 100644 mas-modes-v0.1-spec/CONTRACT.md create mode 100644 mas-modes-v0.1-spec/conformance/messages.json create mode 100644 mas-modes-v0.1-spec/conformance/statute.json create mode 100644 mas-modes-v0.1-spec/conformance/transitions.json create mode 100644 mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json create mode 100644 mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json create mode 100644 mas_modes/__init__.py create mode 100644 mas_modes/conformance/messages.json create mode 100644 mas_modes/conformance/statute.json create mode 100644 mas_modes/conformance/transitions.json create mode 100644 mas_modes/contract.py create mode 100644 mas_modes/email_mode.py create mode 100644 mas_modes/harness.py create mode 100644 mas_modes/mailer.py create mode 100644 mas_modes/schema/mas-event-v0.1.schema.json create mode 100644 mas_modes/schema/mas-modes-v0.1.schema.json create mode 100644 mas_modes/stubs/callback.py create mode 100644 mas_modes/stubs/master.py create mode 100644 tests/test_modes_master.py diff --git a/examples/ticket_service/ticket_service.py b/examples/ticket_service/ticket_service.py index d597bcf..1988084 100644 --- a/examples/ticket_service/ticket_service.py +++ b/examples/ticket_service/ticket_service.py @@ -31,6 +31,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Callable, Optional from mas_core.generator import resource_local_part +from mas_modes.mailer import refusal RESOURCE_TYPE = "ticket" INTAKE_TARGET = "ticket" @@ -147,12 +148,8 @@ class TicketService: def _reply(self, tid, mail_from, msg, subject, body) -> bool: to = msg.get("from") - auto = (msg.get("auto_submitted") or "no").lower() != "no" - bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk") - if not to or auto or bulk: - return False # never auto-reply to automated mail - if self.require_dmarc_pass and (msg.get("authentication") or {}).get("dmarc") != "pass": - return False # unauthenticated sender: keep the ticket, send nothing + if refusal(msg, self.require_dmarc_pass): + return False # automated or unauthenticated sender: keep the ticket, send nothing if not subject.lower().startswith("re:"): subject = "Re: " + subject self.db.execute("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to)" @@ -179,6 +176,26 @@ class TicketService: with smtplib.SMTP(*self.smtp) as s: s.send_message(m) + # -- MasterStore (mas_modes.email_mode): email as master for tickets ------------- + + def open_case(self, response: dict) -> Optional[str]: + return response.get("ticket_id") if response.get("status") in ("created", "correlated") else None + + def case_address(self, tid: str) -> str: + row = self.db.execute("SELECT domain FROM tickets WHERE id=?", (tid,)).fetchone() + return f"{resource_local_part(RESOURCE_TYPE, tid)}@{row['domain']}" + + def record(self, tid: str, kind: str, text: str) -> None: + """A mode status or update in the ticket thread.""" + with self.lock, self.db: + self.db.execute("INSERT INTO messages (ticket_id, disposition, body) VALUES (?,?,?)", + (tid, kind, text)) + + def reply(self, tid: str, msg: dict, subject: str, body: str) -> bool: + """Mail the participant of `msg` from the ticket address, under principle 10.""" + with self.lock, self.db: + return self._reply(tid, self.case_address(tid), msg, subject, body) + # -- admin / inspection ---------------------------------------------------------- def close(self, tid: str) -> bool: diff --git a/mas-modes-v0.1-spec/CONTRACT.md b/mas-modes-v0.1-spec/CONTRACT.md new file mode 100644 index 0000000..10d6749 --- /dev/null +++ b/mas-modes-v0.1-spec/CONTRACT.md @@ -0,0 +1,167 @@ +# MAS mode contract v0.1 (draft) + +How email hands a conversation to another communication mode, and how +email works inside a control flow it does not own. The bundle is +versioned separately from the MAS address specification. In MAS terms, +`ticket!callback@D` *requests* a handoff to `callback` (address spec §7). +This contract defines what can happen to that request. + +The keywords MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119. + +## 1. Concepts + +- **Case.** A unit of communication. It is addressed by a canonical MAS + resource address (`ticket=gs-1@D`), and identified by that address and + its equivalence key (address spec §8). +- **Thread.** The durable record of a case: inbound and outbound mail, plus + every mode status. +- **Mode.** A communication channel other than email (`callback`, `chat`, + ...), reached through a **mode endpoint** that speaks this contract. +- **Handoff.** One attempt to continue a case in a mode. It has a + `handoff_id` and a status (§4). +- **Role.** For each resource type, email is either **master** or **slave** + (§3). The role is fixed for the life of a case. +- **Outside master.** In the slave role, the system that owns the control + flow (a CMS, ticketing or support system, or a framework). + +## 2. Parties and transport + +There are three parties: `email`, `mode` and `master`. Messages are JSON +objects, sent as HTTP `POST` with `Content-Type: application/json`. Every +message carries the envelope: + +| Field | Meaning | +|---|---| +| `mas_modes_version` | `"0.1"` | +| `type` | message type (§5) | +| `id` | sender-chosen unique id; the idempotency key | +| `case` | `{address, key}`: canonical resource address and equivalence key. Absent only in `case.intake` and `case.assign`. | + +- **Idempotency.** A receiver MUST treat a repeated `id` as already + processed, and SHOULD return the original response. +- **Ordering.** `handoff.status` carries `seq`, increasing per handoff. A + receiver MUST ignore a status whose `seq` is not greater than the last + one it accepted, and answers `{"ok": true, "ignored": "stale"}`. +- **Responses.** `2xx` with `{"ok": true, ...}` means the message was + accepted. `4xx` with `{"ok": false, "error": CODE, "detail": ...}` means + it was refused and MUST NOT be retried unchanged. `5xx` or no answer + means the sender MAY retry with the same `id`. + +## 3. Roles + +### 3.1 Email as master + +Email owns the case, the thread and the timeouts. + +1. On `!MODE` to an intake or resource address, email creates or finds the + case. Then it sends `handoff.request` to the mode endpoint configured + for that resource type and mode. If none is configured, or the statute + does not enable the mode, email records a `refused` status itself and + continues by email. +2. Email sets a **deadline** for each handoff. If no terminal status + (§4) has arrived by then, email records `timed-out`, sends + `handoff.cancel` to the mode, and resumes by email. +3. Every status is recorded in the thread. On every terminal status, email + **resumes**: it sends the case's participant one mail from the case + address, saying what happened. Principle 10 (§6) applies. +4. Negotiation: `negotiating` with `needs` makes email ask the participant + by email for the missing details. A reply on the case address that + supplies them is sent to the mode as `handoff.update`. + +### 3.2 Email as slave + +The outside master owns the control flow, case identifiers and timeouts. + +1. **Case identifiers come from the master.** Intake mail (no case yet) is + reported as `case.intake` with an `intake_ref`. The master answers with + `case.assign`, giving the resource id, which email adopts as the case + address `TYPE=ID@D`. Email MUST NOT invent case identifiers in this + role. Until assigned, the intake stays pending. `case.assign` MAY carry + the case's initial `policy`. A mode requested in the intake mail + (`requested_mode`) is handled under that policy once the case exists. +2. Inbound mail on a case is reported as `case.message`. +3. **Sending.** Email sends only on `mail.send` from the master, unless the + case policy (`case.policy`) sets `autonomous_send: true`. Then email + MAY also send acknowledgements and negotiation questions itself. A + `mail.send` is always a reply to a message already recorded on the case + (`in_reply_to`): the recipient is that message's sender. A requested + send can still be refused under §6, and the refusal is the response. + Policy fields the master does not set take the email side's defaults + for the resource type. +4. **Transfers.** The case policy sets `transfers` to `"block"` or to + `{"webhook": URL}`. With `block`, a `!MODE` request is reported as + `handoff.requested` with `"forwarded": false`, and nothing else + happens. With a webhook, email sends `handoff.request` to that URL, + relays every `handoff.status` to the master, and does the negotiation + (3.1 item 4) only when `autonomous_send` is true. +5. **Timeouts belong to the master.** Email sets no deadline and never + records `timed-out`. The master MAY end a handoff with `handoff.cancel`. + +## 4. Handoff status machine + +| From | Allowed next statuses | +|---|---| +| (new) | `requested` | +| `requested` | `negotiating`, `scheduled`, `running`, `refused`, `failed`, `cancelled`, `timed-out` | +| `negotiating` | `negotiating`, `scheduled`, `refused`, `expired`, `cancelled`, `timed-out` | +| `scheduled` | `running`, `attempted`, `expired`, `cancelled`, `timed-out` | +| `attempted` | `scheduled`, `failed`, `expired`, `cancelled`, `timed-out` | +| `running` | `completed`, `failed`, `cancelled`, `timed-out` | + +**Terminal** statuses are `completed`, `failed`, `expired`, `refused`, +`cancelled` and `timed-out`. Nothing follows a terminal status. + +- `requested`: recorded by email when it sends `handoff.request`. +- `negotiating`: the mode needs details. `needs` lists field names + (`phone`, `time`, ...). +- `scheduled`: has `at` (RFC 3339) when known. Going from `attempted` to + `scheduled` is a retry. +- `attempted`: an attempt did not reach the participant. `outcome`, e.g. + `no-answer`. +- `refused`: has `reason`, one of `mode-unavailable`, `mode-not-enabled`, + `declined`, `out-of-hours`, `blocked`. +- `timed-out`: recorded only by the timeout owner: email as master, or the + master's own bookkeeping. A mode MUST NOT send it. +- `cancelled`: after `handoff.cancel`. + +A receiver MUST refuse a transition that the table does not allow +(`error: invalid-transition`). + +## 5. Message types + +| Type | From → to | Payload | +|---|---|---| +| `handoff.request` | email → mode | `handoff_id`, `mode`, `details` (known fields), `status_url` (where statuses go), `participant` (`{address}`) | +| `handoff.update` | email → mode | `handoff_id`, `details` | +| `handoff.cancel` | email → mode, master → email | `handoff_id`, `reason` | +| `handoff.status` | mode → email, email → master (relay) | `handoff_id`, `seq`, `status`, plus `needs` / `at` / `outcome` / `reason` / `detail` | +| `case.intake` | email → master | `intake_ref`, `resource_type`, `event` (gateway event), `requested_mode` | +| `case.assign` | master → email | `intake_ref`, `resource_id`, optional `policy` (`{autonomous_send, transfers}`) | +| `case.message` | email → master | `event`, `message_ref` | +| `case.policy` | master → email | `autonomous_send` (bool), `transfers` (`"block"` or `{"webhook": URL}`) | +| `handoff.requested` | email → master | `handoff_id`, `mode`, `forwarded` (bool) | +| `mail.send` | master → email | `in_reply_to` (a `message_ref`), `subject`, `body` | + +`event` is the normalized gateway event (`schema/mas-event-v0.1.schema.json`). +`message_ref` is the email side's id for a recorded inbound message. + +`mail.send` responses: `{"ok": true, "sent": true}`, or `{"ok": false, +"error": "refused", "reason": R}` with R one of `unauthenticated-sender`, +`automated-sender`, `send-cap-reached`, `unknown-message`. + +## 6. Principle 10 in both roles + +Email MUST NOT send mail to a sender that did not pass DMARC (as read +from trusted `Authentication-Results`), to automated mail (`Auto-Submitted` +other than `no`, or `Precedence: bulk | list | junk`), or to the null +sender. It MUST NOT send once its send cap is reached. These rules hold +whoever asks for the send: email itself as master, the case's autonomous +policy, or a master's `mail.send`. + +## 7. Conformance + +`conformance/` holds valid and invalid messages, the transition table as +data, and scripted flows. Each flow is a sequence of inbound mail, +contract messages and clock ticks, with the expected email-side reaction. +An email-side implementation claims `MAS-MODES-EMAIL-MASTER` +or `MAS-MODES-EMAIL-SLAVE` by passing the flows for that role. diff --git a/mas-modes-v0.1-spec/conformance/messages.json b/mas-modes-v0.1-spec/conformance/messages.json new file mode 100644 index 0000000..c6d0334 --- /dev/null +++ b/mas-modes-v0.1-spec/conformance/messages.json @@ -0,0 +1,440 @@ +{ + "description": "Contract messages: every 'valid' message MUST validate against schema/mas-modes-v0.1.schema.json, every 'invalid' one MUST NOT.", + "valid": [ + { + "case_id": "v-001", + "message": { + "id": "v-001", + "mas_modes_version": "0.1", + "type": "handoff.request", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "v-002", + "message": { + "id": "v-002", + "mas_modes_version": "0.1", + "type": "handoff.update", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "details": { + "phone": "+49 30 1234567", + "time": "tomorrow 10:00" + } + } + }, + { + "case_id": "v-003", + "message": { + "id": "v-003", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 1, + "status": "negotiating", + "needs": [ + "phone", + "time" + ] + } + }, + { + "case_id": "v-004", + "message": { + "id": "v-004", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 2, + "status": "scheduled", + "at": "2026-10-20T10:00:00Z" + } + }, + { + "case_id": "v-005", + "message": { + "id": "v-005", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 3, + "status": "attempted", + "outcome": "no-answer" + } + }, + { + "case_id": "v-006", + "message": { + "id": "v-006", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 4, + "status": "refused", + "reason": "out-of-hours" + } + }, + { + "case_id": "v-007", + "message": { + "id": "v-007", + "mas_modes_version": "0.1", + "type": "handoff.cancel", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "reason": "timed-out" + } + }, + { + "case_id": "v-008", + "message": { + "id": "v-008", + "mas_modes_version": "0.1", + "type": "case.intake", + "intake_ref": "i1", + "resource_type": "case", + "event": { + "mas_event_version": 1 + }, + "requested_mode": null + } + }, + { + "case_id": "v-009", + "message": { + "id": "v-009", + "mas_modes_version": "0.1", + "type": "case.assign", + "intake_ref": "i1", + "resource_id": "EXT-123" + } + }, + { + "case_id": "v-010", + "message": { + "id": "v-010", + "mas_modes_version": "0.1", + "type": "case.message", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "event": {}, + "message_ref": "m7" + } + }, + { + "case_id": "v-011", + "message": { + "id": "v-011", + "mas_modes_version": "0.1", + "type": "case.policy", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "autonomous_send": false, + "transfers": "block" + } + }, + { + "case_id": "v-012", + "message": { + "id": "v-012", + "mas_modes_version": "0.1", + "type": "case.policy", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "transfers": { + "webhook": "http://127.0.0.1:8031/handoffs" + } + } + }, + { + "case_id": "v-013", + "message": { + "id": "v-013", + "mas_modes_version": "0.1", + "type": "handoff.requested", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h2", + "mode": "callback", + "forwarded": false + } + }, + { + "case_id": "v-014", + "message": { + "id": "v-014", + "mas_modes_version": "0.1", + "type": "mail.send", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "in_reply_to": "m7", + "subject": "Re: help", + "body": "We are on it." + } + } + ], + "invalid": [ + { + "case_id": "x-001", + "why": "unknown type", + "message": { + "id": "x-001", + "mas_modes_version": "0.1", + "type": "handoff.start", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "x-002", + "why": "wrong version", + "message": { + "id": "x-002", + "mas_modes_version": "0.2", + "type": "handoff.request", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "x-003", + "why": "negotiating without needs", + "message": { + "id": "x-003", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 1, + "status": "negotiating" + } + }, + { + "case_id": "x-004", + "why": "refused without reason", + "message": { + "id": "x-004", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 4, + "status": "refused" + } + }, + { + "case_id": "x-005", + "why": "refused with an unknown reason", + "message": { + "id": "x-005", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 4, + "status": "refused", + "reason": "busy" + } + }, + { + "case_id": "x-006", + "why": "status not in the machine", + "message": { + "id": "x-006", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 2, + "status": "ringing", + "at": "2026-10-20T10:00:00Z" + } + }, + { + "case_id": "x-007", + "why": "seq must be >= 1", + "message": { + "id": "x-007", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 0, + "status": "scheduled", + "at": "2026-10-20T10:00:00Z" + } + }, + { + "case_id": "x-008", + "why": "attempted without outcome", + "message": { + "id": "x-008", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 3, + "status": "attempted" + } + }, + { + "case_id": "x-009", + "why": "mail.send without in_reply_to (replies only)", + "message": { + "id": "x-009", + "mas_modes_version": "0.1", + "type": "mail.send", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "subject": "Re: help", + "body": "We are on it." + } + }, + { + "case_id": "x-010", + "why": "mail.send naming a recipient", + "message": { + "id": "x-010", + "mas_modes_version": "0.1", + "type": "mail.send", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "in_reply_to": "m7", + "subject": "Re: help", + "body": "We are on it.", + "to": "anyone@example.org" + } + }, + { + "case_id": "x-011", + "why": "transfers neither block nor webhook", + "message": { + "id": "x-011", + "mas_modes_version": "0.1", + "type": "case.policy", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "autonomous_send": false, + "transfers": "allow" + } + }, + { + "case_id": "x-012", + "why": "handoff.request without case", + "message": { + "id": "x-012", + "mas_modes_version": "0.1", + "type": "handoff.request", + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "x-013", + "why": "missing idempotency id", + "message": { + "mas_modes_version": "0.1", + "type": "handoff.update", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "details": { + "phone": "+49 30 1234567", + "time": "tomorrow 10:00" + } + } + } + ] +} diff --git a/mas-modes-v0.1-spec/conformance/statute.json b/mas-modes-v0.1-spec/conformance/statute.json new file mode 100644 index 0000000..dd6d001 --- /dev/null +++ b/mas-modes-v0.1-spec/conformance/statute.json @@ -0,0 +1,18 @@ +{ + "mas_version": "0.2", + "namespace": "modes.example", + "profiles": {"p1": true, "p2": true}, + "resource_types": { + "ticket": {"enabled": true, "description": "Email-mastered ticket"}, + "case": {"enabled": true, "description": "Case owned by an outside master (CMS)"} + }, + "interaction_modes": { + "callback": {"enabled": true, "description": "Continue by a voice callback"}, + "chat": {"enabled": false, "description": "Not offered"} + }, + "equivalence": {"local_part_case": "exact", "dot_folding": false, "resource_id_case": "insensitive"}, + "addresses": { + "ticket": {"kind": "function", "intake_for": "ticket"}, + "case": {"kind": "function", "intake_for": "case"} + } +} diff --git a/mas-modes-v0.1-spec/conformance/transitions.json b/mas-modes-v0.1-spec/conformance/transitions.json new file mode 100644 index 0000000..53564a5 --- /dev/null +++ b/mas-modes-v0.1-spec/conformance/transitions.json @@ -0,0 +1,59 @@ +{ + "description": "Handoff status machine (CONTRACT.md section 4). Statuses not listed as keys are terminal.", + "transitions": { + "(new)": [ + "requested" + ], + "requested": [ + "negotiating", + "scheduled", + "running", + "refused", + "failed", + "cancelled", + "timed-out" + ], + "negotiating": [ + "negotiating", + "scheduled", + "refused", + "expired", + "cancelled", + "timed-out" + ], + "scheduled": [ + "running", + "attempted", + "expired", + "cancelled", + "timed-out" + ], + "attempted": [ + "scheduled", + "failed", + "expired", + "cancelled", + "timed-out" + ], + "running": [ + "completed", + "failed", + "cancelled", + "timed-out" + ] + }, + "terminal": [ + "completed", + "failed", + "expired", + "refused", + "cancelled", + "timed-out" + ], + "recorded_only_by_timeout_owner": [ + "timed-out" + ], + "recorded_by_email": [ + "requested" + ] +} diff --git a/mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json b/mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json new file mode 100644 index 0000000..00866e5 --- /dev/null +++ b/mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json @@ -0,0 +1,281 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mas.coulomb.social/schema/mas-event-v0.1.schema.json", + "title": "Normalized MAS gateway event v0.1", + "description": "What mas-gateway posts to the application for each recipient (pipe or webhook). 'authentication' is only from the receiving MTA's trusted Authentication-Results, or from the inbound provider.", + "type": "object", + "required": [ + "mas_event_version", + "recipient", + "mas", + "message" + ], + "properties": { + "mas_event_version": { + "const": "0.1" + }, + "recipient": { + "type": "string" + }, + "mas": { + "oneOf": [ + { + "type": "object", + "required": [ + "valid", + "domain", + "local_part", + "base", + "target", + "target_syntax", + "discovery", + "namespace_confirmed", + "kind", + "kind_source", + "profile", + "equivalence_key", + "canonical_local_part", + "stand_ins", + "failures", + "unknowns" + ], + "properties": { + "valid": { + "const": true + }, + "domain": { + "type": "string" + }, + "local_part": { + "type": "string" + }, + "base": { + "type": "string" + }, + "target": { + "type": "string" + }, + "target_syntax": { + "type": "string" + }, + "discovery": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "failure": { + "type": "string" + }, + "detail": { + "type": "string" + } + } + }, + "namespace_confirmed": { + "type": "boolean" + }, + "kind": { + "type": "string" + }, + "kind_source": { + "type": "string" + }, + "registry_key": { + "type": [ + "string", + "null" + ] + }, + "intake_for": { + "type": [ + "string", + "null" + ] + }, + "resource_type": { + "type": [ + "string", + "null" + ] + }, + "resource_id": { + "type": [ + "string", + "null" + ] + }, + "facet": { + "type": [ + "string", + "null" + ] + }, + "interaction": { + "type": [ + "string", + "null" + ] + }, + "profile": { + "enum": [ + "P1", + "P2" + ] + }, + "equivalence_key": { + "type": "string" + }, + "canonical_local_part": { + "type": "string" + }, + "stand_ins": { + "type": "array", + "items": { + "enum": [ + "resource", + "interaction" + ] + } + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "unknowns": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "required": [ + "valid", + "error" + ], + "properties": { + "valid": { + "const": false + }, + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "message": { + "type": "object", + "required": [ + "from", + "subject", + "text" + ], + "properties": { + "from": { + "type": [ + "string", + "null" + ] + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "message_id": { + "type": [ + "string", + "null" + ] + }, + "in_reply_to": { + "type": [ + "string", + "null" + ] + }, + "references": { + "type": [ + "string", + "null" + ] + }, + "date": { + "type": [ + "string", + "null" + ] + }, + "auto_submitted": { + "type": [ + "string", + "null" + ] + }, + "precedence": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "authentication": { + "type": [ + "object", + "null" + ], + "properties": { + "dmarc": { + "type": "string" + }, + "spf": { + "type": "string" + }, + "dkim": { + "type": "string" + }, + "header_from": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + }, + "additionalProperties": false +} diff --git a/mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json b/mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json new file mode 100644 index 0000000..b2c1de8 --- /dev/null +++ b/mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json @@ -0,0 +1,689 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mas.coulomb.social/schema/mas-modes-v0.1.schema.json", + "title": "MAS mode contract v0.1 message", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "handoff.request", + "handoff.update", + "handoff.cancel", + "handoff.status", + "case.intake", + "case.assign", + "case.message", + "case.policy", + "handoff.requested", + "mail.send" + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "handoff.request" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.request" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.update" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.update" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.cancel" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.cancel" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.status" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.status" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.intake" + } + } + }, + "then": { + "$ref": "#/$defs/case.intake" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.assign" + } + } + }, + "then": { + "$ref": "#/$defs/case.assign" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.message" + } + } + }, + "then": { + "$ref": "#/$defs/case.message" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.policy" + } + } + }, + "then": { + "$ref": "#/$defs/case.policy" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.requested" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.requested" + } + }, + { + "if": { + "properties": { + "type": { + "const": "mail.send" + } + } + }, + "then": { + "$ref": "#/$defs/mail.send" + } + } + ], + "$defs": { + "case": { + "type": "object", + "required": [ + "address", + "key" + ], + "properties": { + "address": { + "type": "string", + "minLength": 1 + }, + "key": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "status": { + "enum": [ + "requested", + "negotiating", + "scheduled", + "attempted", + "running", + "completed", + "failed", + "expired", + "refused", + "cancelled", + "timed-out" + ] + }, + "handoff.request": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "mode", + "details", + "status_url" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.request" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string", + "minLength": 1 + }, + "details": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + } + }, + "status_url": { + "type": "string", + "format": "uri" + }, + "participant": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "handoff.update": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "details" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.update" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "details": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + } + } + }, + "additionalProperties": false + }, + "handoff.cancel": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.cancel" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "handoff.status": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "seq", + "status" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.status" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "seq": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/$defs/status" + }, + "needs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "at": { + "type": "string" + }, + "outcome": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "detail": { + "type": "string" + } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "negotiating" + } + } + }, + "then": { + "required": [ + "needs" + ] + } + }, + { + "if": { + "properties": { + "status": { + "const": "refused" + } + } + }, + "then": { + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "mode-unavailable", + "mode-not-enabled", + "declined", + "out-of-hours", + "blocked" + ] + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "attempted" + } + } + }, + "then": { + "required": [ + "outcome" + ] + } + } + ] + }, + "case.intake": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "intake_ref", + "resource_type", + "event" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.intake" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "intake_ref": { + "type": "string", + "minLength": 1 + }, + "resource_type": { + "type": "string", + "minLength": 1 + }, + "event": { + "type": "object" + }, + "requested_mode": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "case.assign": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "intake_ref", + "resource_id" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.assign" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "intake_ref": { + "type": "string", + "minLength": 1 + }, + "resource_id": { + "type": "string", + "minLength": 1 + }, + "policy": { + "$ref": "#/$defs/policy" + } + }, + "additionalProperties": false + }, + "case.message": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "event", + "message_ref" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.message" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "event": { + "type": "object" + }, + "message_ref": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "case.policy": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.policy" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "autonomous_send": { + "type": "boolean" + }, + "transfers": { + "$ref": "#/$defs/policy/properties/transfers" + } + }, + "additionalProperties": false + }, + "handoff.requested": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "mode", + "forwarded" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.requested" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string", + "minLength": 1 + }, + "forwarded": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "mail.send": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "in_reply_to", + "body" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "mail.send" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "in_reply_to": { + "type": "string", + "minLength": 1 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + }, + "policy": { + "type": "object", + "properties": { + "autonomous_send": { + "type": "boolean" + }, + "transfers": { + "oneOf": [ + { + "const": "block" + }, + { + "type": "object", + "required": [ + "webhook" + ], + "properties": { + "webhook": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/mas_modes/__init__.py b/mas_modes/__init__.py new file mode 100644 index 0000000..e915eb8 --- /dev/null +++ b/mas_modes/__init__.py @@ -0,0 +1,45 @@ +"""MAS mode contract v0.1: email hands a case to another mode, or works +inside a control flow it does not own (`mas-modes-v0.1-spec/CONTRACT.md`). + +`schema/` and `conformance/` are vendored unchanged from the bundle. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from importlib import resources + +CONTRACT_VERSION = "0.1" + + +def _load(name: str): + return json.loads(resources.files(__name__).joinpath(name).read_text(encoding="utf-8")) + + +@lru_cache(maxsize=None) +def _validator(schema_file: str): + from jsonschema import Draft202012Validator + return Draft202012Validator(_load(f"schema/{schema_file}")) + + +def message_errors(msg: dict) -> list[str]: + """Schema errors for a contract message; empty if valid.""" + return [e.message for e in _validator("mas-modes-v0.1.schema.json").iter_errors(msg)] + + +def event_errors(event: dict) -> list[str]: + """Schema errors for a normalized gateway event; empty if valid.""" + return [e.message for e in _validator("mas-event-v0.1.schema.json").iter_errors(event)] + + +def transitions() -> dict: + return _load("conformance/transitions.json") + + +def message_fixtures() -> dict: + return _load("conformance/messages.json") + + +def flow_fixtures() -> list[dict]: + return _load("conformance/flows.json")["flows"] diff --git a/mas_modes/conformance/messages.json b/mas_modes/conformance/messages.json new file mode 100644 index 0000000..c6d0334 --- /dev/null +++ b/mas_modes/conformance/messages.json @@ -0,0 +1,440 @@ +{ + "description": "Contract messages: every 'valid' message MUST validate against schema/mas-modes-v0.1.schema.json, every 'invalid' one MUST NOT.", + "valid": [ + { + "case_id": "v-001", + "message": { + "id": "v-001", + "mas_modes_version": "0.1", + "type": "handoff.request", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "v-002", + "message": { + "id": "v-002", + "mas_modes_version": "0.1", + "type": "handoff.update", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "details": { + "phone": "+49 30 1234567", + "time": "tomorrow 10:00" + } + } + }, + { + "case_id": "v-003", + "message": { + "id": "v-003", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 1, + "status": "negotiating", + "needs": [ + "phone", + "time" + ] + } + }, + { + "case_id": "v-004", + "message": { + "id": "v-004", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 2, + "status": "scheduled", + "at": "2026-10-20T10:00:00Z" + } + }, + { + "case_id": "v-005", + "message": { + "id": "v-005", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 3, + "status": "attempted", + "outcome": "no-answer" + } + }, + { + "case_id": "v-006", + "message": { + "id": "v-006", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 4, + "status": "refused", + "reason": "out-of-hours" + } + }, + { + "case_id": "v-007", + "message": { + "id": "v-007", + "mas_modes_version": "0.1", + "type": "handoff.cancel", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "reason": "timed-out" + } + }, + { + "case_id": "v-008", + "message": { + "id": "v-008", + "mas_modes_version": "0.1", + "type": "case.intake", + "intake_ref": "i1", + "resource_type": "case", + "event": { + "mas_event_version": 1 + }, + "requested_mode": null + } + }, + { + "case_id": "v-009", + "message": { + "id": "v-009", + "mas_modes_version": "0.1", + "type": "case.assign", + "intake_ref": "i1", + "resource_id": "EXT-123" + } + }, + { + "case_id": "v-010", + "message": { + "id": "v-010", + "mas_modes_version": "0.1", + "type": "case.message", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "event": {}, + "message_ref": "m7" + } + }, + { + "case_id": "v-011", + "message": { + "id": "v-011", + "mas_modes_version": "0.1", + "type": "case.policy", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "autonomous_send": false, + "transfers": "block" + } + }, + { + "case_id": "v-012", + "message": { + "id": "v-012", + "mas_modes_version": "0.1", + "type": "case.policy", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "transfers": { + "webhook": "http://127.0.0.1:8031/handoffs" + } + } + }, + { + "case_id": "v-013", + "message": { + "id": "v-013", + "mas_modes_version": "0.1", + "type": "handoff.requested", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h2", + "mode": "callback", + "forwarded": false + } + }, + { + "case_id": "v-014", + "message": { + "id": "v-014", + "mas_modes_version": "0.1", + "type": "mail.send", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "in_reply_to": "m7", + "subject": "Re: help", + "body": "We are on it." + } + } + ], + "invalid": [ + { + "case_id": "x-001", + "why": "unknown type", + "message": { + "id": "x-001", + "mas_modes_version": "0.1", + "type": "handoff.start", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "x-002", + "why": "wrong version", + "message": { + "id": "x-002", + "mas_modes_version": "0.2", + "type": "handoff.request", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "x-003", + "why": "negotiating without needs", + "message": { + "id": "x-003", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 1, + "status": "negotiating" + } + }, + { + "case_id": "x-004", + "why": "refused without reason", + "message": { + "id": "x-004", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 4, + "status": "refused" + } + }, + { + "case_id": "x-005", + "why": "refused with an unknown reason", + "message": { + "id": "x-005", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 4, + "status": "refused", + "reason": "busy" + } + }, + { + "case_id": "x-006", + "why": "status not in the machine", + "message": { + "id": "x-006", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 2, + "status": "ringing", + "at": "2026-10-20T10:00:00Z" + } + }, + { + "case_id": "x-007", + "why": "seq must be >= 1", + "message": { + "id": "x-007", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 0, + "status": "scheduled", + "at": "2026-10-20T10:00:00Z" + } + }, + { + "case_id": "x-008", + "why": "attempted without outcome", + "message": { + "id": "x-008", + "mas_modes_version": "0.1", + "type": "handoff.status", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "seq": 3, + "status": "attempted" + } + }, + { + "case_id": "x-009", + "why": "mail.send without in_reply_to (replies only)", + "message": { + "id": "x-009", + "mas_modes_version": "0.1", + "type": "mail.send", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "subject": "Re: help", + "body": "We are on it." + } + }, + { + "case_id": "x-010", + "why": "mail.send naming a recipient", + "message": { + "id": "x-010", + "mas_modes_version": "0.1", + "type": "mail.send", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "in_reply_to": "m7", + "subject": "Re: help", + "body": "We are on it.", + "to": "anyone@example.org" + } + }, + { + "case_id": "x-011", + "why": "transfers neither block nor webhook", + "message": { + "id": "x-011", + "mas_modes_version": "0.1", + "type": "case.policy", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "autonomous_send": false, + "transfers": "allow" + } + }, + { + "case_id": "x-012", + "why": "handoff.request without case", + "message": { + "id": "x-012", + "mas_modes_version": "0.1", + "type": "handoff.request", + "handoff_id": "h1", + "mode": "callback", + "details": {}, + "status_url": "http://127.0.0.1:8030/mode-status", + "participant": { + "address": "customer@example.net" + } + } + }, + { + "case_id": "x-013", + "why": "missing idempotency id", + "message": { + "mas_modes_version": "0.1", + "type": "handoff.update", + "case": { + "address": "ticket=gs-1@great-service.example", + "key": "ticket=gs-1" + }, + "handoff_id": "h1", + "details": { + "phone": "+49 30 1234567", + "time": "tomorrow 10:00" + } + } + } + ] +} diff --git a/mas_modes/conformance/statute.json b/mas_modes/conformance/statute.json new file mode 100644 index 0000000..dd6d001 --- /dev/null +++ b/mas_modes/conformance/statute.json @@ -0,0 +1,18 @@ +{ + "mas_version": "0.2", + "namespace": "modes.example", + "profiles": {"p1": true, "p2": true}, + "resource_types": { + "ticket": {"enabled": true, "description": "Email-mastered ticket"}, + "case": {"enabled": true, "description": "Case owned by an outside master (CMS)"} + }, + "interaction_modes": { + "callback": {"enabled": true, "description": "Continue by a voice callback"}, + "chat": {"enabled": false, "description": "Not offered"} + }, + "equivalence": {"local_part_case": "exact", "dot_folding": false, "resource_id_case": "insensitive"}, + "addresses": { + "ticket": {"kind": "function", "intake_for": "ticket"}, + "case": {"kind": "function", "intake_for": "case"} + } +} diff --git a/mas_modes/conformance/transitions.json b/mas_modes/conformance/transitions.json new file mode 100644 index 0000000..53564a5 --- /dev/null +++ b/mas_modes/conformance/transitions.json @@ -0,0 +1,59 @@ +{ + "description": "Handoff status machine (CONTRACT.md section 4). Statuses not listed as keys are terminal.", + "transitions": { + "(new)": [ + "requested" + ], + "requested": [ + "negotiating", + "scheduled", + "running", + "refused", + "failed", + "cancelled", + "timed-out" + ], + "negotiating": [ + "negotiating", + "scheduled", + "refused", + "expired", + "cancelled", + "timed-out" + ], + "scheduled": [ + "running", + "attempted", + "expired", + "cancelled", + "timed-out" + ], + "attempted": [ + "scheduled", + "failed", + "expired", + "cancelled", + "timed-out" + ], + "running": [ + "completed", + "failed", + "cancelled", + "timed-out" + ] + }, + "terminal": [ + "completed", + "failed", + "expired", + "refused", + "cancelled", + "timed-out" + ], + "recorded_only_by_timeout_owner": [ + "timed-out" + ], + "recorded_by_email": [ + "requested" + ] +} diff --git a/mas_modes/contract.py b/mas_modes/contract.py new file mode 100644 index 0000000..08d74fb --- /dev/null +++ b/mas_modes/contract.py @@ -0,0 +1,103 @@ +"""Contract building blocks shared by the email side and the stubs.""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Callable, Optional + +from . import CONTRACT_VERSION, transitions + +_T = transitions() +TERMINAL = frozenset(_T["terminal"]) + + +def allowed(old: Optional[str], new: str) -> bool: + """CONTRACT.md section 4. `old` None means a new handoff.""" + return new in _T["transitions"].get(old or "(new)", []) + + +def message(type_: str, case: Optional[dict] = None, **fields) -> dict: + msg = {"mas_modes_version": CONTRACT_VERSION, "type": type_, "id": uuid.uuid4().hex} + if case is not None: + msg["case"] = case + msg.update({k: v for k, v in fields.items() if v is not None}) + return msg + + +def post(url: str, msg: dict, timeout: float = 10.0) -> tuple[Optional[int], object]: + """POST a contract message. (None, error) when the peer is unreachable.""" + req = urllib.request.Request(url, data=json.dumps(msg).encode(), method="POST", + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, json.loads(r.read().decode() or "null") + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read().decode() or "null") + except (UnicodeDecodeError, json.JSONDecodeError): + return e.code, None + except (OSError, urllib.error.URLError) as e: + return None, str(e) + + +def refuse(error: str, detail: str = "", status: int = 409, **extra) -> tuple[int, dict]: + return status, {"ok": False, "error": error, "detail": detail, **extra} + + +class Idempotent: + """Remember responses by message id (CONTRACT.md section 2).""" + + def __init__(self): + self.seen: dict[str, tuple[int, dict]] = {} + self.lock = threading.Lock() + + def __call__(self, msg: dict, handler: Callable[[dict], tuple[int, dict]]) -> tuple[int, dict]: + mid = msg.get("id") + with self.lock: + if mid in self.seen: + return self.seen[mid] + out = handler(msg) + if mid and out[0] < 500: + with self.lock: + self.seen[mid] = out + return out + + +def json_server(routes: dict[str, Callable[[dict], tuple[int, dict]]], host="127.0.0.1", + port=0) -> ThreadingHTTPServer: + """A loopback JSON-over-HTTP server: POST path -> handler(body) -> (status, body).""" + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + fn = routes.get(self.path) + if fn is None: + return self._send(404, {"ok": False, "error": "not-found"}) + try: + data = json.loads(body.decode()) + except (UnicodeDecodeError, json.JSONDecodeError): + return self._send(400, {"ok": False, "error": "invalid-json"}) + self._send(*fn(data)) + + def _send(self, status, obj): + out = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + + def log_message(self, *a): + pass + + return ThreadingHTTPServer((host, port), Handler) + + +def serve(server: ThreadingHTTPServer) -> str: + """Run in a daemon thread; returns the base URL.""" + threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start() + return f"http://{server.server_address[0]}:{server.server_address[1]}" diff --git a/mas_modes/email_mode.py b/mas_modes/email_mode.py new file mode 100644 index 0000000..240d97c --- /dev/null +++ b/mas_modes/email_mode.py @@ -0,0 +1,432 @@ +"""The email side of the MAS mode contract, in both roles (CONTRACT.md section 3). + +Takes normalized gateway events (POST /mas-events), mode statuses +(POST /mode-status) and, in the slave role, outside-master commands +(POST /master). The role is configured per resource type: + +- master: the case lives in a `MasterStore` (the reference ticket service). + Email negotiates by mail, hands off, records every status in the thread, + owns the deadline (`tick()`) and resumes by mail when a handoff ends. +- slave: an outside master issues case ids, gets every inbound mail as an + event, and decides what email sends and where mode requests go. + +Every outbound mail goes through principle 10 (`mailer.refusal`), whoever +asked for it. Outbound contract messages are queued while the lock is +held and sent after it is released, so peers may call back at once. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Callable, Optional, Protocol, Union + +from mas_core.generator import resource_local_part +from mas_core.parser import MasSyntaxError, parse_address +from mas_core.policy import resolve +from mas_core.statute import Statute + +from . import message_errors +from .contract import TERMINAL, Idempotent, allowed, json_server, message, post, refuse +from .mailer import Mailer + +NOT_ENABLED = {"InteractionModeNotEnabled", "ProfileNotEnabled"} + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS handoffs ( + id TEXT PRIMARY KEY, role TEXT, resource_type TEXT, case_address TEXT, case_key TEXT, + case_ref TEXT, mode TEXT, status TEXT, seq INTEGER DEFAULT 0, deadline REAL, + needs TEXT, participant TEXT, endpoint TEXT, relay_seq INTEGER DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS intakes ( + ref TEXT PRIMARY KEY, resource_type TEXT, domain TEXT, message_ref TEXT, + requested_mode TEXT, resource_id TEXT +); +CREATE TABLE IF NOT EXISTS cases ( + key TEXT PRIMARY KEY, address TEXT, resource_type TEXT, resource_id TEXT, policy TEXT +); +CREATE TABLE IF NOT EXISTS case_messages ( + ref TEXT PRIMARY KEY, case_key TEXT, message TEXT, disposition TEXT +); +""" + + +class MasterStore(Protocol): + """Where email-mastered cases live (implemented by the reference ticket service).""" + + def handle(self, event: dict) -> tuple[int, dict]: ... + def open_case(self, response: dict) -> Optional[str]: ... # case ref, if open + def case_address(self, case_ref: str) -> str: ... + def record(self, case_ref: str, kind: str, text: str) -> None: ... + def reply(self, case_ref: str, msg: dict, subject: str, body: str) -> bool: ... + + +@dataclass +class RoleConfig: + role: str # "master" | "slave" + modes: dict = field(default_factory=dict) # master: mode -> endpoint URL + fields: tuple = ("phone", "time") # details read from "Key: value" lines + timeout: float = 3600.0 # master: handoff deadline (seconds) + master_url: Optional[str] = None # slave: outside master's event URL + autonomous_send: bool = False # slave: default policy + transfers: Union[str, dict] = "block" # slave: "block" | {"webhook": URL} + + +def extract_details(text: str, fields) -> dict: + """`Phone: +49 ...` style lines, for the fields a mode asks for.""" + out = {} + for line in (text or "").splitlines(): + m = re.match(r"\s*([A-Za-z_-]+)\s*:\s*(.+?)\s*$", line) + if m and m.group(1).lower() in fields and m.group(1).lower() not in out: + out[m.group(1).lower()] = m.group(2) + return out + + +def _resume_text(mode: str, status: str, info: dict) -> str: + why = info.get("reason") or info.get("detail") or "" + texts = { + "completed": f"Your {mode} has taken place.", + "failed": f"Your {mode} did not succeed{f' ({why})' if why else ''}.", + "expired": f"Your {mode} request expired{f' ({why})' if why else ''}.", + "refused": f"We cannot continue by {mode} ({why}).", + "cancelled": f"Your {mode} was cancelled.", + "timed-out": f"We could not arrange your {mode} in time.", + } + return texts[status] + " We continue by email: reply to this message." + + +class EmailMode: + def __init__(self, roles: dict[str, RoleConfig], store: Optional[MasterStore] = None, + db_path: str = ":memory:", statutes: Optional[dict[str, Statute]] = None, + clock: Callable[[], float] = time.time, smtp=None, max_sent=None, + require_dmarc_pass: bool = True): + if any(c.role == "master" for c in roles.values()) and store is None: + raise ValueError("a master role needs a MasterStore") + self.roles = roles + self.store = store + self.statutes = statutes or {} + self.clock = clock + self.db = sqlite3.connect(db_path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.executescript(SCHEMA) + self.mailer = Mailer(self.db, smtp, max_sent, require_dmarc_pass) + self.lock = threading.RLock() + self.base_url = "" # set by serve(); status_url = base_url + /mode-status + self._queue: list = [] + self._once = Idempotent() + + # -- HTTP entry points --------------------------------------------------------- + + def server(self, host="127.0.0.1", port=0): + return json_server({"/mas-events": self.handle_event, "/mode-status": self.mode_status, + "/master": self.master_command, + "/tick": lambda body: (200, {"ok": True, "timed_out": self.tick()})}, + host, port) + + def _run(self, fn, *args): + with self.lock, self.db: + out = fn(*args) + self._flush() + return out + + def _send(self, url, msg, on_result=None): + self._queue.append((url, msg, on_result)) + + def _flush(self): + while True: + with self.lock: + if not self._queue: + return + url, msg, on_result = self._queue.pop(0) + status, body = post(url, msg) + if on_result: + with self.lock, self.db: + on_result(status, body) + + # -- inbound mail -------------------------------------------------------------- + + def handle_event(self, event: dict) -> tuple[int, dict]: + mas = event.get("mas", {}) + rtype = mas.get("resource_type") if mas.get("kind") == "resource" else mas.get("intake_for") + cfg = self.roles.get(rtype) if mas.get("valid") and mas.get("namespace_confirmed") else None + if cfg is None: + return self.store.handle(event) if self.store else (404, {"status": "not-handled"}) + if cfg.role == "master": + return self._run(self._master_event, event, rtype, cfg) + return self._run(self._slave_event, event, rtype, cfg) + + def _case(self, address: str) -> dict: + try: + addr = parse_address(address) + except MasSyntaxError: + return {"address": address, "key": address.rsplit("@", 1)[0]} + sem = resolve(addr, self.statutes.get(addr.domain)) + return {"address": address, "key": sem.equivalence_key} + + def _open_handoff(self, case_key): + rows = self.db.execute("SELECT * FROM handoffs WHERE case_key=? ORDER BY rowid", (case_key,)) + return next((r for r in rows if r["status"] not in TERMINAL), None) + + def _master_event(self, event, rtype, cfg): + code, resp = self.store.handle(event) + ref = self.store.open_case(resp) + if ref is None: + return code, resp + mas, msg = event["mas"], event.get("message", {}) + case = self._case(self.store.case_address(ref)) + h = self._open_handoff(case["key"]) + if h is not None: + if h["status"] == "negotiating": + details = extract_details(msg.get("text"), json.loads(h["needs"] or "[]")) + if details: + self.store.record(ref, "mode-update", f"{h['mode']}: details {sorted(details)}") + self._send(h["endpoint"], message("handoff.update", case, + handoff_id=h["id"], details=details)) + return code, {**resp, "handoff_id": h["id"]} + mode = mas.get("interaction") + if not mode: + return code, resp + failures = {f["code"] for f in mas.get("failures", [])} + hid = self._new_handoff("master", rtype, case, ref, mode, msg, + cfg.modes.get(mode), self.clock() + cfg.timeout) + if failures & NOT_ENABLED: + # The creation reply already says so; only an existing case gets a notice. + self._status(hid, "refused", {"reason": "mode-not-enabled"}, + notify=resp.get("status") != "created") + elif mode not in cfg.modes: + self._status(hid, "refused", {"reason": "mode-unavailable"}) + else: + self._request(hid, case, mode, extract_details(msg.get("text"), cfg.fields), msg) + return code, {**resp, "handoff_id": hid} + + def _new_handoff(self, role, rtype, case, ref, mode, msg, endpoint, deadline): + hid = "h-" + uuid.uuid4().hex[:12] + self.db.execute("INSERT INTO handoffs (id, role, resource_type, case_address, case_key," + " case_ref, mode, status, deadline, participant, endpoint)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (hid, role, rtype, case["address"], case["key"], ref, mode, None, + deadline, json.dumps(msg), endpoint)) + return hid + + def _request(self, hid, case, mode, details, msg): + h = self._handoff(hid) + self._status(hid, "requested", {}) + req = message("handoff.request", case, handoff_id=hid, mode=mode, details=details, + status_url=self.base_url + "/mode-status", + participant={"address": msg.get("from")} if msg.get("from") else None) + + def on_result(status, body): + if status is None or status >= 400: + self._status(hid, "refused", {"reason": "mode-unavailable", + "detail": f"mode endpoint answered {status}"}) + self._send(h["endpoint"], req, on_result) + + def _handoff(self, hid): + return self.db.execute("SELECT * FROM handoffs WHERE id=?", (hid,)).fetchone() + + # -- statuses ------------------------------------------------------------------ + + def mode_status(self, msg: dict) -> tuple[int, dict]: + return self._once(msg, lambda m: self._run(self._mode_status, m)) + + def _mode_status(self, msg): + errors = message_errors(msg) + if errors or msg.get("type") != "handoff.status": + return refuse("schema", "; ".join(errors) or "expected handoff.status", 400) + h = self._handoff(msg["handoff_id"]) + if h is None or h["case_key"] != msg["case"]["key"]: + return refuse("unknown-handoff", msg["handoff_id"], 404) + if msg["seq"] <= h["seq"]: + return 200, {"ok": True, "ignored": "stale"} + if msg["status"] == "timed-out": + return refuse("invalid-transition", "only the timeout owner records timed-out") + if not allowed(h["status"], msg["status"]): + return refuse("invalid-transition", f"{h['status']} -> {msg['status']}") + self.db.execute("UPDATE handoffs SET seq=? WHERE id=?", (msg["seq"], h["id"])) + info = {k: msg[k] for k in ("needs", "at", "outcome", "reason", "detail") if k in msg} + self._status(h["id"], msg["status"], info) + return 200, {"ok": True} + + def _status(self, hid, status, info, notify=True): + """Record a status and react to it, per role.""" + h = self._handoff(hid) + self.db.execute("UPDATE handoffs SET status=?, needs=? WHERE id=?", + (status, json.dumps(info.get("needs")) if "needs" in info else h["needs"], hid)) + case = {"address": h["case_address"], "key": h["case_key"]} + participant = json.loads(h["participant"]) + if h["role"] == "master": + self.store.record(h["case_ref"], "mode-status", + f"{h['mode']}: {status}" + "".join(f" {k}={v}" for k, v in info.items())) + if status == "negotiating": + self._ask(h, participant, info["needs"], lambda subj, body: + self.store.reply(h["case_ref"], participant, subj, body)) + elif status in TERMINAL and notify: + self.store.reply(h["case_ref"], participant, participant.get("subject") or "Your request", + _resume_text(h["mode"], status, info)) + return + # slave: the master owns the flow; relay, and ask only when allowed to + cfg = self.roles[h["resource_type"]] + if status != "requested": + self._send(cfg.master_url, message("handoff.status", case, handoff_id=hid, + seq=self._relay_seq(hid), status=status, **info)) + if status == "negotiating" and self._policy(h["case_key"])["autonomous_send"]: + self._ask(h, participant, info["needs"], lambda subj, body: self.mailer.reply( + h["case_key"], h["case_address"], participant, subj, body) is None) + + def _relay_seq(self, hid): + # Statuses relayed to the master are numbered by email, per handoff. + self.db.execute("UPDATE handoffs SET relay_seq=relay_seq+1 WHERE id=?", (hid,)) + return self._handoff(hid)["relay_seq"] + + def _ask(self, h, participant, needs, send): + lines = [f"To arrange the {h['mode']}, please reply to this message with:"] + lines += [f"{n.capitalize()}: ..." for n in needs] + send(participant.get("subject") or "Your request", "\n".join(lines)) + + def tick(self) -> list[str]: + """Master role: time out handoffs past their deadline and resume by email.""" + with self.lock, self.db: + now = self.clock() + rows = self.db.execute("SELECT * FROM handoffs WHERE role='master' AND deadline<=?", + (now,)).fetchall() + out = [] + for h in rows: + if h["status"] in TERMINAL or h["status"] is None: + continue + self._status(h["id"], "timed-out", {}) + if h["endpoint"]: + self._send(h["endpoint"], message( + "handoff.cancel", {"address": h["case_address"], "key": h["case_key"]}, + handoff_id=h["id"], reason="timed-out")) + out.append(h["id"]) + self._flush() + return out + + # -- slave role ---------------------------------------------------------------- + + def _store_message(self, case_key, msg, disposition): + ref = "m-" + uuid.uuid4().hex[:12] + self.db.execute("INSERT INTO case_messages VALUES (?,?,?,?)", + (ref, case_key, json.dumps(msg), disposition)) + return ref + + def _slave_event(self, event, rtype, cfg): + mas, msg = event["mas"], event.get("message", {}) + if mas.get("kind") != "resource": + iref = "i-" + uuid.uuid4().hex[:12] + mref = self._store_message(None, msg, "intake") + self.db.execute("INSERT INTO intakes VALUES (?,?,?,?,?,NULL)", + (iref, rtype, mas["domain"], mref, mas.get("interaction"))) + self._send(cfg.master_url, message("case.intake", intake_ref=iref, resource_type=rtype, + event=event, requested_mode=mas.get("interaction"))) + return 202, {"status": "intake-reported", "intake_ref": iref} + row = self.db.execute("SELECT * FROM cases WHERE key=?", (mas["equivalence_key"],)).fetchone() + if row is None: + self._store_message(None, msg, "quarantined-unknown") + return 202, {"status": "unknown-resource", "action": "quarantined, no reply"} + case = {"address": row["address"], "key": row["key"]} + mref = self._store_message(row["key"], msg, "attached") + self._send(cfg.master_url, message("case.message", case, event=event, message_ref=mref)) + out = {"status": "reported", "case": case, "message_ref": mref} + h = self._open_handoff(row["key"]) + if h is not None and h["status"] == "negotiating" and h["endpoint"]: + details = extract_details(msg.get("text"), json.loads(h["needs"] or "[]")) + if details: + self._send(h["endpoint"], message("handoff.update", case, handoff_id=h["id"], + details=details)) + elif h is None and mas.get("interaction"): + out["handoff_id"] = self._transfer(rtype, cfg, case, mas["interaction"], msg) + return 200, out + + def _policy(self, case_key) -> dict: + row = self.db.execute("SELECT policy FROM cases WHERE key=?", (case_key,)).fetchone() + return json.loads(row["policy"]) + + def _transfer(self, rtype, cfg, case, mode, msg): + """A !MODE request in the slave role: blocked, or sent to the primed webhook.""" + transfers = self._policy(case["key"])["transfers"] + webhook = transfers.get("webhook") if isinstance(transfers, dict) else None + hid = self._new_handoff("slave", rtype, case, case["key"], mode, msg, webhook, None) + self._send(cfg.master_url, message("handoff.requested", case, handoff_id=hid, mode=mode, + forwarded=webhook is not None)) + if webhook is None: + self._status(hid, "requested", {}) + self._status(hid, "refused", {"reason": "blocked"}) + else: + self._request(hid, case, mode, extract_details(msg.get("text"), cfg.fields), msg) + return hid + + def master_command(self, msg: dict) -> tuple[int, dict]: + return self._once(msg, lambda m: self._run(self._master_command, m)) + + def _master_command(self, msg): + errors = message_errors(msg) + if errors: + return refuse("schema", "; ".join(errors), 400) + t = msg["type"] + if t == "case.assign": + return self._assign(msg) + row = self.db.execute("SELECT * FROM cases WHERE key=?", (msg["case"]["key"],)).fetchone() + if row is None: + return refuse("unknown-case", msg["case"]["key"], 404) + if t == "case.policy": + pol = {**self._policy(row["key"]), + **{k: msg[k] for k in ("autonomous_send", "transfers") if k in msg}} + self.db.execute("UPDATE cases SET policy=? WHERE key=?", (json.dumps(pol), row["key"])) + return 200, {"ok": True, "policy": pol} + if t == "mail.send": + m = self.db.execute("SELECT * FROM case_messages WHERE ref=? AND case_key=?", + (msg["in_reply_to"], row["key"])).fetchone() + if m is None: + return refuse("refused", msg["in_reply_to"], reason="unknown-message") + orig = json.loads(m["message"]) + why = self.mailer.reply(row["key"], row["address"], orig, + msg.get("subject") or orig.get("subject") or "Your request", + msg["body"]) + return (200, {"ok": True, "sent": True}) if why is None else refuse("refused", why, reason=why) + if t == "handoff.cancel": + h = self._handoff(msg["handoff_id"]) + if h is None or h["case_key"] != row["key"]: + return refuse("unknown-handoff", msg["handoff_id"], 404) + if h["endpoint"] and h["status"] not in TERMINAL: + self._send(h["endpoint"], message("handoff.cancel", msg["case"], handoff_id=h["id"], + reason=msg.get("reason"))) + return 200, {"ok": True} + return refuse("unsupported", t, 400) + + def _assign(self, msg): + it = self.db.execute("SELECT * FROM intakes WHERE ref=?", (msg["intake_ref"],)).fetchone() + if it is None: + return refuse("unknown-intake", msg["intake_ref"], 404) + if it["resource_id"] is not None: + return refuse("already-assigned", it["resource_id"]) + try: + address = f"{resource_local_part(it['resource_type'], msg['resource_id'])}@{it['domain']}" + except MasSyntaxError as e: + return refuse("invalid-resource-id", str(e), 400) + case = self._case(address) + if self.db.execute("SELECT 1 FROM cases WHERE key=?", (case["key"],)).fetchone(): + return refuse("case-exists", case["key"]) + cfg = self.roles[it["resource_type"]] + pol = {"autonomous_send": cfg.autonomous_send, "transfers": cfg.transfers, + **msg.get("policy", {})} + self.db.execute("INSERT INTO cases VALUES (?,?,?,?,?)", + (case["key"], address, it["resource_type"], msg["resource_id"], json.dumps(pol))) + self.db.execute("UPDATE intakes SET resource_id=? WHERE ref=?", (msg["resource_id"], it["ref"])) + self.db.execute("UPDATE case_messages SET case_key=? WHERE ref=?", (case["key"], it["message_ref"])) + intake = json.loads(self.db.execute("SELECT message FROM case_messages WHERE ref=?", + (it["message_ref"],)).fetchone()["message"]) + out = {"ok": True, "case": case, "message_ref": it["message_ref"]} + if pol["autonomous_send"]: + why = self.mailer.reply(case["key"], address, intake, intake.get("subject") or "Your request", + f"Your request is now case {msg['resource_id']}.\n" + f"Reply to {address} to add to it.") + out["acknowledged"] = why is None + if it["requested_mode"]: + out["handoff_id"] = self._transfer(it["resource_type"], cfg, case, it["requested_mode"], intake) + return 200, out diff --git a/mas_modes/harness.py b/mas_modes/harness.py new file mode 100644 index 0000000..977d159 --- /dev/null +++ b/mas_modes/harness.py @@ -0,0 +1,114 @@ +"""Wire the email side, the stubs and the gateway together on loopback HTTP. + +Used by the tests and by the contract flow runner. Mail enters through the +real gateway (`build_event` from raw RFC 822, with trusted +Authentication-Results), so the whole path is exercised. Time is a fake +clock that only moves on `advance()`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Optional + +from mas_gateway.core import Resolver, gateway, message_meta_from_rfc822 + +from .contract import serve +from .email_mode import EmailMode, RoleConfig +from .stubs.callback import CallbackStub +from .stubs.master import StubMaster + +ROOT = Path(__file__).resolve().parents[1] +CUSTOMER = "customer@example.net" + + +def reference_ticket_service(): + """The reference ticket service (examples/), the master-role store.""" + path = str(ROOT / "examples/ticket_service") + if path not in sys.path: + sys.path.append(path) + from ticket_service import TicketService + return TicketService + + +class FakeClock: + def __init__(self, t: float = 1_000_000.0): + self.t = t + + def __call__(self) -> float: + return self.t + + +class Harness: + """roles: resource type -> {"role": "master"|"slave", ...RoleConfig fields}. + The strings "{callback}" and "{master}" in them are replaced by the stub URLs.""" + + def __init__(self, statute_file, roles: dict, scenario: str = "completed", + auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None): + self.resolver = Resolver.with_statute_files([statute_file]) + [self.domain] = self.resolver.pinned + self.clock = FakeClock() + self.callback = CallbackStub(default=scenario) + self._servers = [] + cb_url = self._serve(self.callback.server()) + "/handoffs" + self.master = StubMaster(auto_assign=auto_assign) + master_url = self._serve(self.master.server()) + "/events" + + def fill(v): + if isinstance(v, str): + return v.replace("{callback}", cb_url).replace("{master}", master_url) + if isinstance(v, dict): + return {k: fill(x) for k, x in v.items()} + return v + configs = {t: RoleConfig(**fill(c)) for t, c in roles.items()} + self.tickets = None + if any(c.role == "master" for c in configs.values()): + kw = {"id_factory": (lambda it=iter(ticket_ids): next(it))} if ticket_ids else {} + self.tickets = reference_ticket_service()(require_dmarc_pass=True, max_sent=max_sent, **kw) + self.email = EmailMode(configs, self.tickets, statutes=dict(self.resolver.pinned), + clock=self.clock, max_sent=max_sent) + self.email.base_url = self._serve(self.email.server()) + self.master.email_url = self.email.base_url + "/master" + self.app_url = self.email.base_url + "/mas-events" + + def _serve(self, server): + self._servers.append(server) + return serve(server) + + def close(self): + for s in self._servers: + s.shutdown() + + # -- driving ------------------------------------------------------------------- + + def mail(self, local_part: str, text: str = "", subject: str = "Help needed", + sender: str = CUSTOMER, dmarc: Optional[str] = "pass", headers: str = ""): + to = f"{local_part}@{self.domain}" + ar = (f"Authentication-Results: {self.domain}; dmarc={dmarc} header.from={sender.split('@')[1]}\r\n" + if dmarc else "") + raw = (f"{ar}From: <{sender}>\r\nTo: {to}\r\nSubject: {subject}\r\n" + f"Message-ID: <{abs(hash((to, subject, text, self.clock.t)))}@example.net>\r\n" + f"{headers}\r\n{text}\r\n").encode() + meta = message_meta_from_rfc822(raw, trusted_authserv_id=self.domain) + [d] = gateway([to], meta, self.resolver, self.app_url) + return d + + def advance(self, seconds: float): + self.clock.t += seconds + return self.email.tick() + + def outbox(self) -> list[dict]: + """Every mail email decided to send, in either role.""" + mails = self.email.mailer.outbox() + if self.tickets is not None: + mails = self.tickets.outbox() + mails + return mails + + def thread(self, ticket_id: str) -> list[str]: + """Mode entries recorded in a master-role ticket thread.""" + t = self.tickets.ticket(ticket_id) + return [m["body"] for m in t["messages"] if m["disposition"].startswith("mode-")] + + def statuses(self, handoff_id: str) -> list[str]: + return [m["status"] for m in self.callback.handoffs[handoff_id].sent] diff --git a/mas_modes/mailer.py b/mas_modes/mailer.py new file mode 100644 index 0000000..185fa08 --- /dev/null +++ b/mas_modes/mailer.py @@ -0,0 +1,82 @@ +"""Outbound mail under principle 10 (CONTRACT.md section 6). + +Every send is a reply to a recorded inbound message: the recipient is that +message's sender, and the message's own sender authentication decides +whether email may answer at all. +""" + +from __future__ import annotations + +import smtplib +import sqlite3 +from email.message import EmailMessage +from typing import Optional + +OUTBOX = """ +CREATE TABLE IF NOT EXISTS mode_outbox ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, case_ref TEXT, mail_from TEXT, mail_to TEXT, + subject TEXT, body TEXT, in_reply_to TEXT, sent INTEGER DEFAULT 0 +); +""" + + +def refusal(msg: dict, require_dmarc_pass: bool = True) -> Optional[str]: + """Why email must not answer `msg`, or None if it may.""" + auto = (msg.get("auto_submitted") or "no").lower() != "no" + bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk") + if not msg.get("from") or auto or bulk: + return "automated-sender" + if require_dmarc_pass and (msg.get("authentication") or {}).get("dmarc") != "pass": + return "unauthenticated-sender" + return None + + +class Mailer: + """Gated replies, recorded in an outbox; sent over SMTP when configured, + up to a lifetime cap counted in the outbox.""" + + def __init__(self, db: sqlite3.Connection, smtp: Optional[tuple[str, int]] = None, + max_sent: Optional[int] = None, require_dmarc_pass: bool = True): + self.db = db + self.db.executescript(OUTBOX) + self.smtp = smtp + self.max_sent = max_sent + self.require_dmarc_pass = require_dmarc_pass + + def reply(self, case_ref: str, mail_from: str, msg: dict, subject: str, + body: str) -> Optional[str]: + """Answer `msg` from `mail_from`. Returns None when accepted, else the reason.""" + why = refusal(msg, self.require_dmarc_pass) + if why: + return why + if self.cap_reached(): + return "send-cap-reached" + if not subject.lower().startswith("re:"): + subject = "Re: " + subject + cur = self.db.execute( + "INSERT INTO mode_outbox (case_ref, mail_from, mail_to, subject, body, in_reply_to)" + " VALUES (?,?,?,?,?,?)", (case_ref, mail_from, msg["from"], subject, body, + msg.get("message_id"))) + if self.smtp: + self._send(mail_from, msg["from"], subject, body, msg.get("message_id")) + self.db.execute("UPDATE mode_outbox SET sent=1 WHERE seq=?", (cur.lastrowid,)) + return None + + def cap_reached(self) -> bool: + if self.max_sent is None: + return False + n = self.db.execute("SELECT COUNT(*) FROM mode_outbox WHERE sent=1").fetchone()[0] + return n >= self.max_sent + + def outbox(self) -> list[dict]: + return [dict(r) for r in self.db.execute("SELECT * FROM mode_outbox ORDER BY seq")] + + def _send(self, mail_from, to, subject, body, in_reply_to): + m = EmailMessage() + m["From"], m["To"], m["Subject"] = mail_from, to, subject + m["Auto-Submitted"] = "auto-replied" + if in_reply_to: + m["In-Reply-To"] = m["References"] = in_reply_to + m.set_content(body) + with smtplib.SMTP(*self.smtp) as s: + s.send_message(m) diff --git a/mas_modes/schema/mas-event-v0.1.schema.json b/mas_modes/schema/mas-event-v0.1.schema.json new file mode 100644 index 0000000..00866e5 --- /dev/null +++ b/mas_modes/schema/mas-event-v0.1.schema.json @@ -0,0 +1,281 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mas.coulomb.social/schema/mas-event-v0.1.schema.json", + "title": "Normalized MAS gateway event v0.1", + "description": "What mas-gateway posts to the application for each recipient (pipe or webhook). 'authentication' is only from the receiving MTA's trusted Authentication-Results, or from the inbound provider.", + "type": "object", + "required": [ + "mas_event_version", + "recipient", + "mas", + "message" + ], + "properties": { + "mas_event_version": { + "const": "0.1" + }, + "recipient": { + "type": "string" + }, + "mas": { + "oneOf": [ + { + "type": "object", + "required": [ + "valid", + "domain", + "local_part", + "base", + "target", + "target_syntax", + "discovery", + "namespace_confirmed", + "kind", + "kind_source", + "profile", + "equivalence_key", + "canonical_local_part", + "stand_ins", + "failures", + "unknowns" + ], + "properties": { + "valid": { + "const": true + }, + "domain": { + "type": "string" + }, + "local_part": { + "type": "string" + }, + "base": { + "type": "string" + }, + "target": { + "type": "string" + }, + "target_syntax": { + "type": "string" + }, + "discovery": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "failure": { + "type": "string" + }, + "detail": { + "type": "string" + } + } + }, + "namespace_confirmed": { + "type": "boolean" + }, + "kind": { + "type": "string" + }, + "kind_source": { + "type": "string" + }, + "registry_key": { + "type": [ + "string", + "null" + ] + }, + "intake_for": { + "type": [ + "string", + "null" + ] + }, + "resource_type": { + "type": [ + "string", + "null" + ] + }, + "resource_id": { + "type": [ + "string", + "null" + ] + }, + "facet": { + "type": [ + "string", + "null" + ] + }, + "interaction": { + "type": [ + "string", + "null" + ] + }, + "profile": { + "enum": [ + "P1", + "P2" + ] + }, + "equivalence_key": { + "type": "string" + }, + "canonical_local_part": { + "type": "string" + }, + "stand_ins": { + "type": "array", + "items": { + "enum": [ + "resource", + "interaction" + ] + } + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "unknowns": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "required": [ + "valid", + "error" + ], + "properties": { + "valid": { + "const": false + }, + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "message": { + "type": "object", + "required": [ + "from", + "subject", + "text" + ], + "properties": { + "from": { + "type": [ + "string", + "null" + ] + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "message_id": { + "type": [ + "string", + "null" + ] + }, + "in_reply_to": { + "type": [ + "string", + "null" + ] + }, + "references": { + "type": [ + "string", + "null" + ] + }, + "date": { + "type": [ + "string", + "null" + ] + }, + "auto_submitted": { + "type": [ + "string", + "null" + ] + }, + "precedence": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "authentication": { + "type": [ + "object", + "null" + ], + "properties": { + "dmarc": { + "type": "string" + }, + "spf": { + "type": "string" + }, + "dkim": { + "type": "string" + }, + "header_from": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + }, + "additionalProperties": false +} diff --git a/mas_modes/schema/mas-modes-v0.1.schema.json b/mas_modes/schema/mas-modes-v0.1.schema.json new file mode 100644 index 0000000..b2c1de8 --- /dev/null +++ b/mas_modes/schema/mas-modes-v0.1.schema.json @@ -0,0 +1,689 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mas.coulomb.social/schema/mas-modes-v0.1.schema.json", + "title": "MAS mode contract v0.1 message", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "handoff.request", + "handoff.update", + "handoff.cancel", + "handoff.status", + "case.intake", + "case.assign", + "case.message", + "case.policy", + "handoff.requested", + "mail.send" + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "handoff.request" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.request" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.update" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.update" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.cancel" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.cancel" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.status" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.status" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.intake" + } + } + }, + "then": { + "$ref": "#/$defs/case.intake" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.assign" + } + } + }, + "then": { + "$ref": "#/$defs/case.assign" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.message" + } + } + }, + "then": { + "$ref": "#/$defs/case.message" + } + }, + { + "if": { + "properties": { + "type": { + "const": "case.policy" + } + } + }, + "then": { + "$ref": "#/$defs/case.policy" + } + }, + { + "if": { + "properties": { + "type": { + "const": "handoff.requested" + } + } + }, + "then": { + "$ref": "#/$defs/handoff.requested" + } + }, + { + "if": { + "properties": { + "type": { + "const": "mail.send" + } + } + }, + "then": { + "$ref": "#/$defs/mail.send" + } + } + ], + "$defs": { + "case": { + "type": "object", + "required": [ + "address", + "key" + ], + "properties": { + "address": { + "type": "string", + "minLength": 1 + }, + "key": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "status": { + "enum": [ + "requested", + "negotiating", + "scheduled", + "attempted", + "running", + "completed", + "failed", + "expired", + "refused", + "cancelled", + "timed-out" + ] + }, + "handoff.request": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "mode", + "details", + "status_url" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.request" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string", + "minLength": 1 + }, + "details": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + } + }, + "status_url": { + "type": "string", + "format": "uri" + }, + "participant": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "handoff.update": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "details" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.update" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "details": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + } + } + }, + "additionalProperties": false + }, + "handoff.cancel": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.cancel" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "handoff.status": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "seq", + "status" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.status" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "seq": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/$defs/status" + }, + "needs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "at": { + "type": "string" + }, + "outcome": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "detail": { + "type": "string" + } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "negotiating" + } + } + }, + "then": { + "required": [ + "needs" + ] + } + }, + { + "if": { + "properties": { + "status": { + "const": "refused" + } + } + }, + "then": { + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "mode-unavailable", + "mode-not-enabled", + "declined", + "out-of-hours", + "blocked" + ] + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "attempted" + } + } + }, + "then": { + "required": [ + "outcome" + ] + } + } + ] + }, + "case.intake": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "intake_ref", + "resource_type", + "event" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.intake" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "intake_ref": { + "type": "string", + "minLength": 1 + }, + "resource_type": { + "type": "string", + "minLength": 1 + }, + "event": { + "type": "object" + }, + "requested_mode": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "case.assign": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "intake_ref", + "resource_id" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.assign" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "intake_ref": { + "type": "string", + "minLength": 1 + }, + "resource_id": { + "type": "string", + "minLength": 1 + }, + "policy": { + "$ref": "#/$defs/policy" + } + }, + "additionalProperties": false + }, + "case.message": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "event", + "message_ref" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.message" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "event": { + "type": "object" + }, + "message_ref": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "case.policy": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "case.policy" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "autonomous_send": { + "type": "boolean" + }, + "transfers": { + "$ref": "#/$defs/policy/properties/transfers" + } + }, + "additionalProperties": false + }, + "handoff.requested": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "handoff_id", + "mode", + "forwarded" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "handoff.requested" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "handoff_id": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string", + "minLength": 1 + }, + "forwarded": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "mail.send": { + "type": "object", + "required": [ + "mas_modes_version", + "type", + "id", + "case", + "in_reply_to", + "body" + ], + "properties": { + "mas_modes_version": { + "const": "0.1" + }, + "type": { + "const": "mail.send" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "case": { + "$ref": "#/$defs/case" + }, + "in_reply_to": { + "type": "string", + "minLength": 1 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + }, + "policy": { + "type": "object", + "properties": { + "autonomous_send": { + "type": "boolean" + }, + "transfers": { + "oneOf": [ + { + "const": "block" + }, + { + "type": "object", + "required": [ + "webhook" + ], + "properties": { + "webhook": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/mas_modes/stubs/callback.py b/mas_modes/stubs/callback.py new file mode 100644 index 0000000..421af54 --- /dev/null +++ b/mas_modes/stubs/callback.py @@ -0,0 +1,154 @@ +"""Callback mode stub (CONTRACT.md; INTENT.md, Callback stub). + +Speaks the mode contract and simulates how a callback can develop. It never +calls anyone. Scenarios are scripted and deterministic: nothing happens +until `step()` (or POST /step) emits the next status. A scenario is chosen +by the request's `details.scenario`, or else by the stub's default. + + stub = CallbackStub(default="no-answer-retry") + base = serve(stub.server()) # POST /handoffs, /step +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from typing import Optional + +from .. import 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 + +# 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 +} + + +@dataclass +class Handoff: + request: dict + script: list + details: dict + seq: int = 0 + pos: int = 0 + status: Optional[str] = None + sent: list = field(default_factory=list) + + +class CallbackStub: + def __init__(self, default: str = "completed"): + if default not in SCENARIOS: + raise ValueError(f"unknown scenario {default!r}") + self.default = default + self.handoffs: dict[str, Handoff] = {} + self.received: list[dict] = [] + self.lock = threading.Lock() + self._once = Idempotent() + + # -- contract endpoint --------------------------------------------------------- + + def receive(self, msg: dict) -> tuple[int, dict]: + return self._once(msg, self._receive) + + def _receive(self, msg): + errors = message_errors(msg) + if errors: + return refuse("schema", "; ".join(errors), 400) + with self.lock: + self.received.append(msg) + t, hid = msg["type"], msg["handoff_id"] + if t == "handoff.request": + name = msg["details"].get("scenario", self.default) + if name not in SCENARIOS: + return refuse("unknown-scenario", name, 400) + script = list(SCENARIOS[name]) + details = dict(msg["details"]) + if script and script[0] == WAIT and all(details.get(k) for k in NEEDED): + script.pop(0) # nothing to negotiate + self.handoffs[hid] = Handoff(msg, script, details) + return 202, {"ok": True, "handoff_id": hid} + h = self.handoffs.get(hid) + if h is None: + return refuse("unknown-handoff", hid, 404) + if t == "handoff.update": + h.details.update(msg["details"]) + return 200, {"ok": True} + if t == "handoff.cancel": + if h.status not in TERMINAL: + h.script[h.pos:] = [("cancelled", {"detail": msg.get("reason", "")})] + return 200, {"ok": True} + return refuse("unsupported", t, 400) + + # -- simulation ---------------------------------------------------------------- + + def step(self, handoff_id: Optional[str] = None) -> Optional[dict]: + """Emit the next status of one handoff (the oldest one with a step due). + + Returns the status message sent, or None if nothing is due.""" + with self.lock: + h = self._due(handoff_id) + if h is None: + return None + item = h.script[h.pos] + if item == WAIT: + missing = [k for k in NEEDED if not h.details.get(k)] + if not missing: + h.pos += 2 # past the wait, and the step it releases + item = h.script[h.pos - 1] + elif h.status == "negotiating": + return None # still waiting for handoff.update + else: + item = ("negotiating", {"needs": missing}) + else: + h.pos += 1 + status, extra = item + h.seq += 1 + h.status = status + msg = message("handoff.status", h.request["case"], handoff_id=h.request["handoff_id"], + seq=h.seq, status=status, **extra) + h.sent.append(msg) + url = h.request["status_url"] + post(url, msg) + return msg + + def run(self, handoff_id: Optional[str] = None, limit: int = 20) -> list[dict]: + """Step until nothing more is due (terminal, waiting, or silent).""" + out = [] + for _ in range(limit): + m = self.step(handoff_id) + if m is None: + break + out.append(m) + return out + + def _due(self, handoff_id): + for hid, h in self.handoffs.items(): + if handoff_id not in (None, hid) or h.status in TERMINAL: + continue + if h.pos < len(h.script): + return h + return None + + def server(self, host="127.0.0.1", port=0): + return json_server({"/handoffs": self.receive, + "/step": lambda body: (200, {"ok": True, "sent": self.step(body.get("handoff_id"))})}, + host, port) diff --git a/mas_modes/stubs/master.py b/mas_modes/stubs/master.py new file mode 100644 index 0000000..5c92809 --- /dev/null +++ b/mas_modes/stubs/master.py @@ -0,0 +1,79 @@ +"""A minimal outside master (CMS / ticketing stand-in) for email in the slave role. + +It records every message email sends it, issues case identifiers, and +drives email through the contract: assign, policy, mail.send, cancel. +Nothing happens on its own: `pump()` performs queued auto-assignments, so +tests stay deterministic and no handler calls back into email. + + master = StubMaster(email_url="http://127.0.0.1:PORT/master", auto_assign=True) + base = serve(master.server()) # POST /events +""" + +from __future__ import annotations + +import itertools +import threading +from typing import Optional + +from .. import message_errors +from ..contract import Idempotent, json_server, message, post, refuse + + +class StubMaster: + def __init__(self, email_url: Optional[str] = None, auto_assign: bool = False, + id_prefix: str = "EXT-"): + self.email_url = email_url + self.auto_assign = auto_assign + self.ids = (f"{id_prefix}{n}" for n in itertools.count(100)) + self.received: list[dict] = [] + self.pending_intakes: list[str] = [] + self.lock = threading.Lock() + self._once = Idempotent() + + def receive(self, msg: dict) -> tuple[int, dict]: + return self._once(msg, self._receive) + + def _receive(self, msg): + errors = message_errors(msg) + if errors: + return refuse("schema", "; ".join(errors), 400) + with self.lock: + self.received.append(msg) + if msg["type"] == "case.intake": + self.pending_intakes.append(msg["intake_ref"]) + return 200, {"ok": True} + + def of_type(self, type_: str) -> list[dict]: + return [m for m in self.received if m["type"] == type_] + + # -- commands to email ------------------------------------------------------- + + def command(self, type_: str, case: Optional[dict] = None, **fields) -> tuple[Optional[int], object]: + return post(self.email_url, message(type_, case, **fields)) + + def pump(self) -> list[tuple]: + """Assign case ids to queued intakes (when auto_assign is on).""" + out = [] + while self.auto_assign: + with self.lock: + if not self.pending_intakes: + break + ref = self.pending_intakes.pop(0) + out.append(self.assign(ref, next(self.ids))) + return out + + def assign(self, intake_ref: str, resource_id: str): + return self.command("case.assign", intake_ref=intake_ref, resource_id=resource_id) + + def policy(self, case: dict, autonomous_send: Optional[bool] = None, transfers=None): + return self.command("case.policy", case, autonomous_send=autonomous_send, transfers=transfers) + + def send(self, case: dict, in_reply_to: str, body: str, subject: Optional[str] = None): + return self.command("mail.send", case, in_reply_to=in_reply_to, body=body, subject=subject) + + def cancel(self, case: dict, handoff_id: str, reason: str = "timed-out"): + """The master owns timeouts in the slave role: it ends a handoff itself.""" + return self.command("handoff.cancel", case, handoff_id=handoff_id, reason=reason) + + def server(self, host="127.0.0.1", port=0): + return json_server({"/events": self.receive}, host, port) diff --git a/pyproject.toml b/pyproject.toml index dfa5ac8..c0e33e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,12 @@ mas = "mas_cli.main:main" mas-gateway = "mas_gateway.main:main" [tool.setuptools] -packages = ["mas_core", "mas_conformance", "mas_cli", "mas_gateway"] +packages = ["mas_core", "mas_conformance", "mas_cli", "mas_gateway", "mas_modes", "mas_modes.stubs"] [tool.setuptools.package-data] mas_core = ["schema/*.json"] mas_conformance = ["*.json", "statutes/*.json"] +mas_modes = ["schema/*.json", "conformance/*.json"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_modes_master.py b/tests/test_modes_master.py new file mode 100644 index 0000000..ef0f125 --- /dev/null +++ b/tests/test_modes_master.py @@ -0,0 +1,146 @@ +"""Email as master (CONTRACT.md 3.1) against the callback stub (MAS-WP-0010 T04/T05).""" +import pytest + +from mas_modes.contract import message, post +from mas_modes.harness import Harness, ROOT + +STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json" +ROLES = {"ticket": {"role": "master", "modes": {"callback": "{callback}"}, + "fields": ("phone", "time", "scenario"), "timeout": 3600}} +DETAILS = "Phone: +49 30 1234567\nTime: tomorrow 10:00" + + +@pytest.fixture +def h(): + harness = Harness(STATUTE, ROLES, ticket_ids=(f"gs-{n}" for n in range(1, 50))) + yield harness + harness.close() + + +def start(h, text=DETAILS, local="ticket!callback", **kw): + d = h.mail(local, text, **kw) + assert d.status == 201, d.response + return d.response["ticket_id"], d.response["handoff_id"] + + +def bodies(h): + return [m["body"] for m in h.outbox()] + + +def test_completed(h): + tid, hid = start(h, DETAILS + "\nScenario: completed") + [req] = h.callback.received + assert req["type"] == "handoff.request" and req["details"]["phone"] == "+49 30 1234567" + assert req["case"] == {"address": f"ticket={tid}@modes.example", "key": f"ticket={tid}"} + h.callback.run() + assert h.statuses(hid) == ["scheduled", "running", "completed"] + assert h.thread(tid) == ["callback: requested", "callback: scheduled at=2026-10-20T10:00:00Z", + "callback: running", "callback: completed"] + assert "has taken place" in bodies(h)[-1] + assert h.outbox()[-1]["mail_from"] == f"ticket={tid}@modes.example" + + +def test_negotiation_over_email(h): + tid, hid = start(h, "Please call me.\nScenario: negotiate") + h.callback.run() + assert h.statuses(hid) == ["negotiating"] + question = bodies(h)[-1] + assert "Phone: ..." in question and "Time: ..." in question + d = h.mail(f"ticket={tid}", DETAILS, subject="Re: Help needed") + assert d.response["status"] == "correlated" and d.response["handoff_id"] == hid + assert h.callback.received[-1]["type"] == "handoff.update" + h.callback.run() + assert h.statuses(hid) == ["negotiating", "scheduled", "running", "completed"] + + +def test_details_in_first_mail_skip_negotiation(h): + tid, hid = start(h, DETAILS + "\nScenario: negotiate") + h.callback.run() + assert h.statuses(hid) == ["scheduled", "running", "completed"] + + +@pytest.mark.parametrize("scenario, statuses, text", [ + ("no-answer-retry", ["scheduled", "attempted", "scheduled", "running", "completed"], "has taken place"), + ("no-answer-fail", ["scheduled", "attempted", "scheduled", "attempted", "failed"], "did not succeed"), + ("call-failed", ["scheduled", "running", "failed"], "line dropped"), + ("expired", ["scheduled", "expired"], "expired"), + ("unavailable", ["refused"], "mode-unavailable"), + ("declined", ["refused"], "declined"), + ("out-of-hours", ["refused"], "out-of-hours"), +]) +def test_outcomes_resume_by_email(h, scenario, statuses, text): + tid, hid = start(h, f"{DETAILS}\nScenario: {scenario}") + h.callback.run() + assert h.statuses(hid) == statuses + assert text in bodies(h)[-1] and "We continue by email" in bodies(h)[-1] + + +def test_email_owns_the_timeout(h): + tid, hid = start(h, DETAILS + "\nScenario: silent") + assert h.callback.run() == [] + assert h.advance(3599) == [] + assert h.advance(2) == [hid] + assert h.callback.received[-1]["type"] == "handoff.cancel" + assert "could not arrange" in bodies(h)[-1] + assert h.thread(tid)[-1] == "callback: timed-out" + assert h.advance(10_000) == [] # only once + + +def test_mode_not_enabled_by_statute(h): + d = h.mail("ticket!chat", "hi") + assert d.status == 201 and h.callback.received == [] + assert h.thread(d.response["ticket_id"]) == ["chat: refused reason=mode-not-enabled"] + assert len(h.outbox()) == 1 # only the creation reply, which explains it + + +def test_mode_endpoint_down(): + roles = {"ticket": {**ROLES["ticket"], "modes": {"callback": "http://127.0.0.1:9/handoffs"}}} + h = Harness(STATUTE, roles) + try: + d = h.mail("ticket!callback", DETAILS) + tid = d.response["ticket_id"] + assert h.thread(tid)[-1].startswith("callback: refused reason=mode-unavailable") + assert "mode-unavailable" in [m["body"] for m in h.outbox()][-1] + finally: + h.close() + + +def test_handoff_on_existing_ticket(h): + d = h.mail("ticket", "no mode") + tid = d.response["ticket_id"] + d = h.mail(f"ticket={tid}!callback", DETAILS) + assert d.response["status"] == "correlated" and "handoff_id" in d.response + assert h.callback.received[-1]["type"] == "handoff.request" + + +def test_unauthenticated_sender_gets_no_mail(h): + tid, hid = start(h, "Scenario: negotiate", dmarc="fail") + h.callback.run() + assert h.statuses(hid) == ["negotiating"] + assert h.outbox() == [] # no creation reply, no question: principle 10 + + +def test_contract_guards(h): + tid, hid = start(h, DETAILS + "\nScenario: completed") + case = h.callback.run()[0]["case"] + url = h.email.base_url + "/mode-status" + stale = message("handoff.status", case, handoff_id=hid, seq=1, status="running") + assert post(url, stale) == (200, {"ok": True, "ignored": "stale"}) + code, body = post(url, message("handoff.status", case, handoff_id=hid, seq=99, status="timed-out")) + assert code == 409 and body["error"] == "invalid-transition" + code, body = post(url, message("handoff.status", case, handoff_id=hid, seq=99, status="negotiating", + needs=["phone"])) + assert code == 409 # completed is terminal + code, body = post(url, message("handoff.status", case, handoff_id="nope", seq=1, status="running")) + assert code == 404 + code, body = post(url, {"type": "handoff.status"}) + assert code == 400 and body["error"] == "schema" + + +def test_repeated_message_id_is_processed_once(h): + tid, hid = start(h, DETAILS + "\nScenario: silent") + case = h.callback.received[0]["case"] + url = h.email.base_url + "/mode-status" + msg = message("handoff.status", case, handoff_id=hid, seq=1, status="scheduled", at="x") + assert post(url, msg) == post(url, msg) == (200, {"ok": True}) + assert h.thread(tid).count("callback: scheduled at=x") == 1 diff --git a/workplans/MAS-WP-0010-mode-reference-implementation.md b/workplans/MAS-WP-0010-mode-reference-implementation.md index e887811..23830c6 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: proposed +status: active owner: claude topic_slug: coulomb-social created: "2026-10-19" @@ -37,7 +37,7 @@ done. ```task id: MAS-WP-0010-T01 -status: todo +status: done priority: high state_hub_task_id: "1b4915f8-a6c4-5d0f-8d51-0e0437e0ebc6" ``` @@ -63,7 +63,7 @@ Review the design with the founder before T02. ```task id: MAS-WP-0010-T02 -status: todo +status: progress priority: high state_hub_task_id: "e0af967d-15e8-50ad-8930-ad569224cddf" ``` @@ -78,7 +78,7 @@ for `mas_conformance`. ```task id: MAS-WP-0010-T03 -status: todo +status: done priority: medium state_hub_task_id: "045bb7b0-59b3-57a6-b226-cf268b7c9690" ``` @@ -92,7 +92,7 @@ item, so slave-mode masters can consume inbound events directly. ```task id: MAS-WP-0010-T04 -status: todo +status: done priority: high state_hub_task_id: "1552686b-da8c-518e-8e81-49a6f7d312ff" ``` @@ -112,7 +112,7 @@ stub to show that the contract is generic. ```task id: MAS-WP-0010-T05 -status: todo +status: done priority: high state_hub_task_id: "8b75fec4-97e3-540f-b377-aa4e0166e1c4" ```