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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
tegwick 2026-10-19 10:57:20 +02:00
parent fb36aaef81
commit e91cd01a02
22 changed files with 4327 additions and 13 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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"
}
}
}
]
}

View file

@ -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"}
}
}

View file

@ -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"
]
}

View file

@ -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
}

View file

@ -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
}
}
}

45
mas_modes/__init__.py Normal file
View file

@ -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"]

View file

@ -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"
}
}
}
]
}

View file

@ -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"}
}
}

View file

@ -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"
]
}

103
mas_modes/contract.py Normal file
View file

@ -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]}"

432
mas_modes/email_mode.py Normal file
View file

@ -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

114
mas_modes/harness.py Normal file
View file

@ -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]

82
mas_modes/mailer.py Normal file
View file

@ -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)

View file

@ -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
}

View file

@ -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
}
}
}

154
mas_modes/stubs/callback.py Normal file
View file

@ -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)

79
mas_modes/stubs/master.py Normal file
View file

@ -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)

View file

@ -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"]

146
tests/test_modes_master.py Normal file
View file

@ -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

View file

@ -4,7 +4,7 @@ type: workplan
title: "Mode reference implementation: contract, email master/slave roles, stubs"
domain: communication
repo: mail-address-semantics
status: 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"
```