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:
parent
fb36aaef81
commit
e91cd01a02
22 changed files with 4327 additions and 13 deletions
|
|
@ -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:
|
||||
|
|
|
|||
167
mas-modes-v0.1-spec/CONTRACT.md
Normal file
167
mas-modes-v0.1-spec/CONTRACT.md
Normal 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.
|
||||
440
mas-modes-v0.1-spec/conformance/messages.json
Normal file
440
mas-modes-v0.1-spec/conformance/messages.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
18
mas-modes-v0.1-spec/conformance/statute.json
Normal file
18
mas-modes-v0.1-spec/conformance/statute.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
59
mas-modes-v0.1-spec/conformance/transitions.json
Normal file
59
mas-modes-v0.1-spec/conformance/transitions.json
Normal 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"
|
||||
]
|
||||
}
|
||||
281
mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json
Normal file
281
mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json
Normal 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
|
||||
}
|
||||
689
mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json
Normal file
689
mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json
Normal 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
45
mas_modes/__init__.py
Normal 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"]
|
||||
440
mas_modes/conformance/messages.json
Normal file
440
mas_modes/conformance/messages.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
18
mas_modes/conformance/statute.json
Normal file
18
mas_modes/conformance/statute.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
59
mas_modes/conformance/transitions.json
Normal file
59
mas_modes/conformance/transitions.json
Normal 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
103
mas_modes/contract.py
Normal 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
432
mas_modes/email_mode.py
Normal 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
114
mas_modes/harness.py
Normal 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
82
mas_modes/mailer.py
Normal 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)
|
||||
281
mas_modes/schema/mas-event-v0.1.schema.json
Normal file
281
mas_modes/schema/mas-event-v0.1.schema.json
Normal 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
|
||||
}
|
||||
689
mas_modes/schema/mas-modes-v0.1.schema.json
Normal file
689
mas_modes/schema/mas-modes-v0.1.schema.json
Normal 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
154
mas_modes/stubs/callback.py
Normal 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
79
mas_modes/stubs/master.py
Normal 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)
|
||||
|
|
@ -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
146
tests/test_modes_master.py
Normal 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
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue