MAS-WP-0010 finished: email slave role, stub outside master, contract flows, SCP-8
- EmailMode slave role: master-issued case ids, events to the master, sends only on instruction or autonomous policy, transfers blocked or to a primed webhook, timeouts left to the master; principle 10 refusals reported back. - conformance/flows.json (13 flows) + mas_modes.runner; mas test runs the mode corpus (172/172). - SCP-8 / v0.2 section 7.1: !MODE requests a handoff defined by the mode contract. docs/reuse-surface.md. - Deploy image copies mas_modes (ticket service imports its mailer). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
parent
e91cd01a02
commit
87be0d1da3
16 changed files with 1396 additions and 17 deletions
|
|
@ -15,9 +15,12 @@ reference experiment (see `INTENT.md` and `workplans/MAS-WP-0002-*`).
|
|||
| `mas_conformance/` | Shared corpus: spec fixtures (vendored, drift-tested), property cases, statute cases |
|
||||
| `mas_cli/` | `mas parse / explain / discover / compose / lint-statute / test` |
|
||||
| `mas_gateway/` | Generic inbound mail → MAS event → HTTP (`pipe` for MTAs, `webhook` for providers) |
|
||||
| `mas_modes/` | Mode contract v0.1: email side as master or slave (`email_mode`), callback stub, stub outside master, flow runner |
|
||||
| `mas-modes-v0.1-spec/` | Mode contract bundle: `CONTRACT.md`, schemas (contract + gateway event), conformance flows |
|
||||
| `examples/statute/` | Static `/.well-known/mas` for `great-service.example` + tiny server |
|
||||
| `examples/ticket_service/` | Reference app proving create / correlate / closed / unknown |
|
||||
| `docs/experiment-review.md` | Milestone 9 findings |
|
||||
| `docs/reuse-surface.md` | What a multimodal framework can depend on |
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
|
|||
25
SCOPE.md
25
SCOPE.md
|
|
@ -41,13 +41,15 @@ delivery stays external.
|
|||
tested against a real Postfix by `experiments/postfix-probe/` and
|
||||
`tests/test_postfix_profile.py` (needs Docker). Exim: notes only
|
||||
(`integrations/exim/NOTES.md`).
|
||||
- **Mode reference implementation** (new; INTENT.md, *Mode reference
|
||||
implementation*). A language-neutral mode contract (handoffs, statuses,
|
||||
timeouts, master/slave roles, outside-master commands) with JSON schemas
|
||||
and fixtures. The email side implements both roles: master per resource
|
||||
type, or slave to an outside master that issues case ids. Test stubs: a
|
||||
scripted callback mode and a fake outside master. Email is the only real
|
||||
channel.
|
||||
- **Mode reference implementation** (INTENT.md, *Mode reference
|
||||
implementation*). `mas-modes-v0.1-spec/` is a language-neutral contract
|
||||
(handoffs, statuses, timeouts, master/slave roles, outside-master
|
||||
commands) with JSON schemas, including the gateway event, and
|
||||
conformance flows. `mas_modes/` holds the email side in both roles
|
||||
(master per resource type over the ticket service, or slave to an
|
||||
outside master that issues case ids), a scripted callback stub, a stub
|
||||
outside master and the flow runner. Email is the only real channel. See
|
||||
`docs/reuse-surface.md`.
|
||||
- **Live test environment.** `deploy/railiance/`: the MX for
|
||||
`mas.coulomb.social`, with replies through Scaleway TEM capped at 300.
|
||||
It is temporary; MAS-WP-0007 retires it.
|
||||
|
|
@ -76,15 +78,16 @@ delivery stays external.
|
|||
- Status: active. The reference experiment (MAS-WP-0002) passed its
|
||||
decision gate on 2026-10-18; the findings are in
|
||||
`docs/experiment-review.md`.
|
||||
- Finished: MAS-WP-0001 to 0006 and 0008. Postfix profile, v0.1.1 errata,
|
||||
- Finished: MAS-WP-0001 to 0006, 0008 and 0010. Postfix profile, v0.1.1 errata,
|
||||
v0.2 draft, address composer, and v0.2 conformance cases.
|
||||
- Open: MAS-WP-0007 (retire the live MX; backlog, unblocked) and
|
||||
MAS-WP-0009 (declare v0.2 final; waits for a second implementation or
|
||||
deployment).
|
||||
- v0.2 stays a draft (founder, 2026-10-19).
|
||||
- P2 is decided: `!MODE` requests a handoff, defined by the mode contract
|
||||
(founder, 2026-10-19). The mode contract and stubs are not started yet;
|
||||
they need a workplan.
|
||||
- P2 is decided: `!MODE` requests a handoff (founder, 2026-10-19; SCP-8,
|
||||
v0.2 §7.1). The mode contract v0.1 draft and the reference
|
||||
implementation are done (MAS-WP-0010); `mas test` runs the contract
|
||||
corpus.
|
||||
- The live `mas-mx` pod runs an image built before the profile refactor.
|
||||
- Tests: `.venv/bin/pytest` runs 199 tests, offline apart from loopback
|
||||
fixtures and the Docker probe.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
|
|||
RUN useradd -r -s /usr/sbin/nologin mas
|
||||
COPY mas_core /app/mas_core
|
||||
COPY mas_gateway /app/mas_gateway
|
||||
COPY mas_modes /app/mas_modes
|
||||
COPY examples /app/examples
|
||||
COPY experiments/postfix-probe/capture.py /app/capture.py
|
||||
COPY integrations /app/integrations
|
||||
|
|
|
|||
49
docs/reuse-surface.md
Normal file
49
docs/reuse-surface.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Reuse surface
|
||||
|
||||
This repository is the email building block for a multimodal communication
|
||||
framework that is yet to be designed (INTENT.md, *Mode reference
|
||||
implementation*). This page says what such a framework can depend on, and
|
||||
how stable each part is.
|
||||
|
||||
## Depend on these
|
||||
|
||||
| Part | Where | Stability |
|
||||
|---|---|---|
|
||||
| MAS address spec | `mas-v0.1-spec/` (v0.1.1), `mas-v0.2-spec/` (draft) | v0.1.1 stable. v0.2 draft until a second implementation passes its corpus (MAS-WP-0009) |
|
||||
| Mode contract | `mas-modes-v0.1-spec/` | v0.1 draft. Changes are versioned; messages carry `mas_modes_version` |
|
||||
| Gateway event | `mas-modes-v0.1-spec/schema/mas-event-v0.1.schema.json` | v0.1. Events carry `mas_event_version` |
|
||||
| Conformance corpora | the bundles' `conformance/` | Normative for their version. Consume them unchanged |
|
||||
| `mas_core` | parse (no I/O), discover (exact host), resolve (pure), generate / `for_destination` | Python reference library |
|
||||
| `mas_gateway` | MTA `pipe` and provider `webhook` → event | Reference adapter. The event is the interface, not the code |
|
||||
| Postfix profile | `integrations/postfix/` | Tested against Postfix 3.7 (`tests/test_postfix_profile.py`) |
|
||||
|
||||
## Integrating as an outside master
|
||||
|
||||
Configure the resource type as `slave` (`mas_modes.email_mode.RoleConfig`)
|
||||
and point `master_url` at your system. You then:
|
||||
|
||||
- receive `case.intake` and `case.message`, each carrying a gateway event;
|
||||
- answer `case.assign` with your own case id; email adopts it as
|
||||
`TYPE=ID@domain`;
|
||||
- send replies with `mail.send` (`in_reply_to` a recorded message);
|
||||
principle 10 can refuse them;
|
||||
- decide per case on `autonomous_send` and `transfers` (`block`, or your
|
||||
mode webhook);
|
||||
- own every timeout; end a handoff with `handoff.cancel`.
|
||||
|
||||
## Integrating a mode
|
||||
|
||||
Implement the mode endpoint in CONTRACT.md: accept `handoff.request`,
|
||||
`handoff.update` and `handoff.cancel`, and post `handoff.status` to
|
||||
`status_url` with an increasing `seq`. Never send `timed-out`.
|
||||
`mas_modes/stubs/callback.py` is a complete example.
|
||||
|
||||
## Examples only, not products
|
||||
|
||||
- `examples/ticket_service/`: the reference ticket service, used as the
|
||||
store for email-mastered cases.
|
||||
- `mas_modes/stubs/`: the callback stub and the stub outside master.
|
||||
They never contact a person or a service.
|
||||
- `mas_modes/email_mode.py`: the reference email side. It is
|
||||
single-process, with SQLite and no authentication on its HTTP endpoints,
|
||||
so it is fit for loopback and tests. The contract is the interface.
|
||||
|
|
@ -299,6 +299,34 @@ case-insensitive by design.
|
|||
|
||||
---
|
||||
|
||||
## SCP-8: `!MODE` requests a handoff, defined by the mode contract
|
||||
|
||||
**Section:** §7 Interaction semantics.
|
||||
|
||||
**Evidence:** phase 1 showed that `!callback` expresses intent, but that a
|
||||
callback still needs a number and a time, so it starts a negotiation. The
|
||||
founder decided on 2026-10-19 (INTENT.md, phase 2) that `!MODE` requests a
|
||||
*handoff* from email to `MODE`. MAS-WP-0010 implements this as a separate
|
||||
contract (`mas-modes-v0.1-spec/`), with email as master or as slave per
|
||||
resource type.
|
||||
|
||||
**Proposed text (§7, appended):**
|
||||
|
||||
> A receiver that acts on an interaction request treats it as a request
|
||||
> to hand the conversation over to `MODE`. What can happen to that request
|
||||
> (negotiation, scheduling, completion, failure, refusal, timeout) and
|
||||
> who owns the conversation meanwhile is defined by the MAS mode
|
||||
> contract, which is versioned separately (`mas-modes-v0.1-spec/`).
|
||||
> Email remains the durable thread when it is the master. The handoff
|
||||
> stays a request: refusal is a normal outcome, and the address grants
|
||||
> nothing.
|
||||
|
||||
**Impact:** informative for parsers and generators: there is no change to
|
||||
the grammar, the statute or the conformance corpus. Receivers that
|
||||
implement handoffs get a contract to conform to.
|
||||
|
||||
---
|
||||
|
||||
## Lower-priority gaps (no proposal yet)
|
||||
|
||||
Recorded in the review, not urgent:
|
||||
|
|
|
|||
22
mas-modes-v0.1-spec/README.md
Normal file
22
mas-modes-v0.1-spec/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# MAS mode contract v0.1 (draft): specification bundle
|
||||
|
||||
How email hands a case to another communication mode, and how email works
|
||||
as one element of a control flow it does not own. It is versioned
|
||||
separately from the MAS address specification (`mas-v0.2-spec/`, §7).
|
||||
|
||||
## Contents
|
||||
|
||||
- `CONTRACT.md`: normative text (roles, transport, status machine,
|
||||
message types, principle 10)
|
||||
- `schema/mas-modes-v0.1.schema.json`: every contract message
|
||||
- `schema/mas-event-v0.1.schema.json`: the normalized gateway event
|
||||
(`mas-gateway` → application, and `case.intake` / `case.message`
|
||||
payloads)
|
||||
- `conformance/transitions.json`: the status machine as data
|
||||
- `conformance/messages.json`: valid and invalid messages
|
||||
- `conformance/flows.json`: scripted flows for `MAS-MODES-EMAIL-MASTER` and
|
||||
`MAS-MODES-EMAIL-SLAVE`
|
||||
- `conformance/statute.json`: the statute the flows run under
|
||||
|
||||
The reference email side, the callback stub and the stub outside master are
|
||||
in `mas_modes/`. `mas test` runs this bundle against them.
|
||||
426
mas-modes-v0.1-spec/conformance/flows.json
Normal file
426
mas-modes-v0.1-spec/conformance/flows.json
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
{
|
||||
"description": "Scripted flows (CONTRACT.md section 7). Each runs against a fresh email side with the callback stub and, for 'slave', a stub outside master that assigns EXT-100, EXT-101, ... The statute is statute.json. Placeholders: {case_id}, {message_ref}, {handoff_id} come from the latest response that carried them; {callback} is the stub's handoff URL. Master role config: ticket, modes {callback}, fields phone/time, timeout 3600 s. Slave role config: case, defaults autonomous_send false, transfers block, overridden by 'config'.",
|
||||
"flows": [
|
||||
{
|
||||
"id": "m-001",
|
||||
"role": "master",
|
||||
"title": "callback completes",
|
||||
"scenario": "completed",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
],
|
||||
"last_mail_contains": "has taken place"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-002",
|
||||
"role": "master",
|
||||
"title": "negotiation over email",
|
||||
"scenario": "negotiate",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Please call me."
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"negotiating"
|
||||
],
|
||||
"last_mail_contains": "Phone: ..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket={case_id}",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "correlated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"negotiating",
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
],
|
||||
"last_mail_contains": "has taken place"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-003",
|
||||
"role": "master",
|
||||
"title": "no answer, retried, completed",
|
||||
"scenario": "no-answer-retry",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"scheduled",
|
||||
"attempted",
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-004",
|
||||
"role": "master",
|
||||
"title": "no answer twice, failed",
|
||||
"scenario": "no-answer-fail",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"scheduled",
|
||||
"attempted",
|
||||
"scheduled",
|
||||
"attempted",
|
||||
"failed"
|
||||
],
|
||||
"last_mail_contains": "did not succeed"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-005",
|
||||
"role": "master",
|
||||
"title": "refused out of hours",
|
||||
"scenario": "out-of-hours",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"refused"
|
||||
],
|
||||
"last_mail_contains": "out-of-hours"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-006",
|
||||
"role": "master",
|
||||
"title": "email owns the timeout",
|
||||
"scenario": "silent",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"advance": 3599,
|
||||
"expect": {
|
||||
"timed_out": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"advance": 2,
|
||||
"expect": {
|
||||
"timed_out": 1,
|
||||
"mode_received_last": "handoff.cancel",
|
||||
"last_mail_contains": "could not arrange"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-007",
|
||||
"role": "master",
|
||||
"title": "mode not enabled by the statute",
|
||||
"scenario": "completed",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!chat",
|
||||
"text": "hi"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created",
|
||||
"mode_received": 0,
|
||||
"outbox_count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-008",
|
||||
"role": "master",
|
||||
"title": "unauthenticated sender gets no mail",
|
||||
"scenario": "negotiate",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "call me",
|
||||
"dmarc": "fail"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"negotiating"
|
||||
],
|
||||
"outbox_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-001",
|
||||
"role": "slave",
|
||||
"title": "master issues the id; email sends only on instruction",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case",
|
||||
"text": "My order is late"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "intake-reported",
|
||||
"master_received": {
|
||||
"case.intake": 1
|
||||
},
|
||||
"outbox_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true,
|
||||
"expect": {
|
||||
"case_address": "case=EXT-100@modes.example"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mail": {
|
||||
"to": "case={case_id}",
|
||||
"text": "more"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "reported",
|
||||
"master_received": {
|
||||
"case.message": 1
|
||||
},
|
||||
"outbox_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"master": {
|
||||
"command": "send",
|
||||
"in_reply_to": "{message_ref}",
|
||||
"body": "We are on it."
|
||||
},
|
||||
"expect": {
|
||||
"command_ok": true,
|
||||
"outbox_count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-002",
|
||||
"role": "slave",
|
||||
"title": "principle 10 refuses a master send",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case",
|
||||
"text": "hi",
|
||||
"dmarc": "fail"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true
|
||||
},
|
||||
{
|
||||
"master": {
|
||||
"command": "send",
|
||||
"in_reply_to": "{message_ref}",
|
||||
"body": "x"
|
||||
},
|
||||
"expect": {
|
||||
"command_ok": false,
|
||||
"command_reason": "unauthenticated-sender",
|
||||
"outbox_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-003",
|
||||
"role": "slave",
|
||||
"title": "transfer blocked",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true,
|
||||
"expect": {
|
||||
"master_received": {
|
||||
"handoff.requested": 1,
|
||||
"handoff.status": 1
|
||||
},
|
||||
"mode_received": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-004",
|
||||
"role": "slave",
|
||||
"title": "transfer to the primed webhook; statuses relayed",
|
||||
"config": {
|
||||
"transfers": {
|
||||
"webhook": "{callback}"
|
||||
}
|
||||
},
|
||||
"scenario": "completed",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true,
|
||||
"expect": {
|
||||
"mode_received": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"relayed": [
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
],
|
||||
"outbox_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"advance": 1000000,
|
||||
"expect": {
|
||||
"timed_out": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-005",
|
||||
"role": "slave",
|
||||
"title": "the master owns the timeout",
|
||||
"config": {
|
||||
"transfers": {
|
||||
"webhook": "{callback}"
|
||||
}
|
||||
},
|
||||
"scenario": "silent",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"relayed": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"master": {
|
||||
"command": "cancel",
|
||||
"handoff_id": "{handoff_id}"
|
||||
},
|
||||
"expect": {
|
||||
"command_ok": true,
|
||||
"mode_received_last": "handoff.cancel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"relayed": [
|
||||
"cancelled"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -24,6 +24,10 @@ New features, opt-in per statute with `"mas_version": "0.2"`
|
|||
unquoted form. Receivers treat them identically; generators emit the
|
||||
unquoted form; canonical parsers still reject the quoted form (§4).
|
||||
Fixture p1-040 note updated.
|
||||
- **Handoffs (SCP-8), §7.1.** `!MODE` requests a handoff whose behavior
|
||||
the separate MAS mode contract defines (`mas-modes-v0.1-spec/`,
|
||||
MAS-WP-0010). It is informative for parsers and generators, with no
|
||||
grammar or statute change.
|
||||
- **Conformance (MAS-WP-0008).** `conformance/statute-aware.json` and
|
||||
the `MAS-V0.2-STATUTE-AWARE` surface cover the stand-in table, the
|
||||
`resource_id_case` equivalence and destination-transform refusals.
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ case names one of the file's inline statutes, whose `namespace` is the host.
|
|||
`reason` is informative, and refusal codes are not normative.
|
||||
`allow_lossy` marks an explicit request for a drop transform.
|
||||
|
||||
Handoff behavior (§7.1) is not tested here. Its conformance surfaces
|
||||
(`MAS-MODES-EMAIL-MASTER`, `MAS-MODES-EMAIL-SLAVE`) and fixtures are in
|
||||
`mas-modes-v0.1-spec/`.
|
||||
|
||||
## 6. Discovery failure
|
||||
|
||||
Failure to retrieve or validate a statute:
|
||||
|
|
|
|||
|
|
@ -310,6 +310,18 @@ MUST be a resource type the statute declares or inherits. Without such a
|
|||
registration a target has no intake semantics, even if it equals a declared
|
||||
resource type.
|
||||
|
||||
### 7.1 Handoffs (v0.2)
|
||||
|
||||
A receiver that acts on an interaction request treats it as a request to
|
||||
hand the conversation over to `MODE`. What can happen to that request
|
||||
(negotiation, scheduling, completion, failure, refusal, timeout), and who
|
||||
owns the conversation meanwhile, is defined by the MAS mode contract. The
|
||||
contract is versioned separately (`mas-modes-v0.1-spec/`, draft). Email
|
||||
remains the durable thread when it is the master. The handoff stays a
|
||||
request: refusal is a normal outcome, and the address grants nothing.
|
||||
|
||||
This section changes no grammar, statute field or address fixture.
|
||||
|
||||
## 8. Address equivalence
|
||||
|
||||
MAS does not impose case folding, dot folding, alias folding, or Unicode
|
||||
|
|
|
|||
|
|
@ -242,7 +242,12 @@ def cmd_test(args):
|
|||
print(f"v0.2 statute-aware corpus: {aware.passed}/{aware.passed + len(aware.failures)} passed")
|
||||
for fid, subject, why in aware.failures:
|
||||
print(f" FAIL {fid} {subject!r}: {why}")
|
||||
return 0 if rep.ok and not st_fail and aware.ok else 1
|
||||
from mas_modes.runner import run_mode_corpus
|
||||
modes = run_mode_corpus()
|
||||
print(f"mode contract v0.1 corpus: {modes.passed}/{modes.passed + len(modes.failures)} passed")
|
||||
for fid, why in modes.failures:
|
||||
print(f" FAIL {fid}: {why}")
|
||||
return 0 if rep.ok and not st_fail and aware.ok and modes.ok else 1
|
||||
|
||||
|
||||
def build_parser():
|
||||
|
|
|
|||
426
mas_modes/conformance/flows.json
Normal file
426
mas_modes/conformance/flows.json
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
{
|
||||
"description": "Scripted flows (CONTRACT.md section 7). Each runs against a fresh email side with the callback stub and, for 'slave', a stub outside master that assigns EXT-100, EXT-101, ... The statute is statute.json. Placeholders: {case_id}, {message_ref}, {handoff_id} come from the latest response that carried them; {callback} is the stub's handoff URL. Master role config: ticket, modes {callback}, fields phone/time, timeout 3600 s. Slave role config: case, defaults autonomous_send false, transfers block, overridden by 'config'.",
|
||||
"flows": [
|
||||
{
|
||||
"id": "m-001",
|
||||
"role": "master",
|
||||
"title": "callback completes",
|
||||
"scenario": "completed",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
],
|
||||
"last_mail_contains": "has taken place"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-002",
|
||||
"role": "master",
|
||||
"title": "negotiation over email",
|
||||
"scenario": "negotiate",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Please call me."
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"negotiating"
|
||||
],
|
||||
"last_mail_contains": "Phone: ..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket={case_id}",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "correlated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"negotiating",
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
],
|
||||
"last_mail_contains": "has taken place"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-003",
|
||||
"role": "master",
|
||||
"title": "no answer, retried, completed",
|
||||
"scenario": "no-answer-retry",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"scheduled",
|
||||
"attempted",
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-004",
|
||||
"role": "master",
|
||||
"title": "no answer twice, failed",
|
||||
"scenario": "no-answer-fail",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"scheduled",
|
||||
"attempted",
|
||||
"scheduled",
|
||||
"attempted",
|
||||
"failed"
|
||||
],
|
||||
"last_mail_contains": "did not succeed"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-005",
|
||||
"role": "master",
|
||||
"title": "refused out of hours",
|
||||
"scenario": "out-of-hours",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"refused"
|
||||
],
|
||||
"last_mail_contains": "out-of-hours"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-006",
|
||||
"role": "master",
|
||||
"title": "email owns the timeout",
|
||||
"scenario": "silent",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"advance": 3599,
|
||||
"expect": {
|
||||
"timed_out": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"advance": 2,
|
||||
"expect": {
|
||||
"timed_out": 1,
|
||||
"mode_received_last": "handoff.cancel",
|
||||
"last_mail_contains": "could not arrange"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-007",
|
||||
"role": "master",
|
||||
"title": "mode not enabled by the statute",
|
||||
"scenario": "completed",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!chat",
|
||||
"text": "hi"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created",
|
||||
"mode_received": 0,
|
||||
"outbox_count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "m-008",
|
||||
"role": "master",
|
||||
"title": "unauthenticated sender gets no mail",
|
||||
"scenario": "negotiate",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "ticket!callback",
|
||||
"text": "call me",
|
||||
"dmarc": "fail"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "created"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"statuses": [
|
||||
"negotiating"
|
||||
],
|
||||
"outbox_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-001",
|
||||
"role": "slave",
|
||||
"title": "master issues the id; email sends only on instruction",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case",
|
||||
"text": "My order is late"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "intake-reported",
|
||||
"master_received": {
|
||||
"case.intake": 1
|
||||
},
|
||||
"outbox_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true,
|
||||
"expect": {
|
||||
"case_address": "case=EXT-100@modes.example"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mail": {
|
||||
"to": "case={case_id}",
|
||||
"text": "more"
|
||||
},
|
||||
"expect": {
|
||||
"response_status": "reported",
|
||||
"master_received": {
|
||||
"case.message": 1
|
||||
},
|
||||
"outbox_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"master": {
|
||||
"command": "send",
|
||||
"in_reply_to": "{message_ref}",
|
||||
"body": "We are on it."
|
||||
},
|
||||
"expect": {
|
||||
"command_ok": true,
|
||||
"outbox_count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-002",
|
||||
"role": "slave",
|
||||
"title": "principle 10 refuses a master send",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case",
|
||||
"text": "hi",
|
||||
"dmarc": "fail"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true
|
||||
},
|
||||
{
|
||||
"master": {
|
||||
"command": "send",
|
||||
"in_reply_to": "{message_ref}",
|
||||
"body": "x"
|
||||
},
|
||||
"expect": {
|
||||
"command_ok": false,
|
||||
"command_reason": "unauthenticated-sender",
|
||||
"outbox_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-003",
|
||||
"role": "slave",
|
||||
"title": "transfer blocked",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true,
|
||||
"expect": {
|
||||
"master_received": {
|
||||
"handoff.requested": 1,
|
||||
"handoff.status": 1
|
||||
},
|
||||
"mode_received": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-004",
|
||||
"role": "slave",
|
||||
"title": "transfer to the primed webhook; statuses relayed",
|
||||
"config": {
|
||||
"transfers": {
|
||||
"webhook": "{callback}"
|
||||
}
|
||||
},
|
||||
"scenario": "completed",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true,
|
||||
"expect": {
|
||||
"mode_received": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"relayed": [
|
||||
"scheduled",
|
||||
"running",
|
||||
"completed"
|
||||
],
|
||||
"outbox_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"advance": 1000000,
|
||||
"expect": {
|
||||
"timed_out": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s-005",
|
||||
"role": "slave",
|
||||
"title": "the master owns the timeout",
|
||||
"config": {
|
||||
"transfers": {
|
||||
"webhook": "{callback}"
|
||||
}
|
||||
},
|
||||
"scenario": "silent",
|
||||
"steps": [
|
||||
{
|
||||
"mail": {
|
||||
"to": "case!callback",
|
||||
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pump": true
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"relayed": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"master": {
|
||||
"command": "cancel",
|
||||
"handoff_id": "{handoff_id}"
|
||||
},
|
||||
"expect": {
|
||||
"command_ok": true,
|
||||
"mode_received_last": "handoff.cancel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "run",
|
||||
"expect": {
|
||||
"relayed": [
|
||||
"cancelled"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
170
mas_modes/runner.py
Normal file
170
mas_modes/runner.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Run the mode-contract conformance bundle against this email side.
|
||||
|
||||
Message fixtures and the transition table are checked against the schema
|
||||
and `contract.allowed`; flows run through `harness.Harness` with the real
|
||||
gateway, the callback stub and the stub outside master. Used by `mas test`
|
||||
and pytest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from importlib import resources
|
||||
|
||||
from . import flow_fixtures, message_errors, message_fixtures, transitions
|
||||
from .contract import allowed
|
||||
from .harness import Harness
|
||||
|
||||
MASTER = {"role": "master", "modes": {"callback": "{callback}"},
|
||||
"fields": ("phone", "time", "scenario"), "timeout": 3600}
|
||||
SLAVE = {"role": "slave", "master_url": "{master}"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
passed: int = 0
|
||||
failures: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.failures
|
||||
|
||||
|
||||
def check_messages(report: Report):
|
||||
fx = message_fixtures()
|
||||
for c in fx["valid"]:
|
||||
errs = message_errors(c["message"])
|
||||
(report.failures.append((c["case_id"], f"should be valid: {errs}")) if errs
|
||||
else _ok(report))
|
||||
for c in fx["invalid"]:
|
||||
(report.failures.append((c["case_id"], f"should be invalid ({c['why']})"))
|
||||
if not message_errors(c["message"]) else _ok(report))
|
||||
t = transitions()
|
||||
statuses = set(t["terminal"]) | set(t["transitions"]) - {"(new)"}
|
||||
for old in [None, *sorted(statuses)]:
|
||||
for new in sorted(statuses):
|
||||
want = new in t["transitions"].get(old or "(new)", [])
|
||||
if allowed(old, new) != want:
|
||||
report.failures.append((f"transition {old}->{new}", f"expected allowed={want}"))
|
||||
else:
|
||||
_ok(report)
|
||||
|
||||
|
||||
def _ok(report):
|
||||
report.passed += 1
|
||||
|
||||
|
||||
def _fill(value, ctx):
|
||||
if isinstance(value, str):
|
||||
for k, v in ctx.items():
|
||||
value = value.replace("{" + k + "}", str(v))
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {k: _fill(v, ctx) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _statute_path():
|
||||
return resources.files(__package__).joinpath("conformance/statute.json")
|
||||
|
||||
|
||||
def run_flow(flow: dict) -> list[str]:
|
||||
"""Returns the problems found; empty when the flow passes."""
|
||||
with resources.as_file(_statute_path()) as statute:
|
||||
if flow["role"] == "master":
|
||||
roles = {"ticket": MASTER}
|
||||
else:
|
||||
roles = {"case": {**SLAVE, **flow.get("config", {})}}
|
||||
h = Harness(statute, roles, scenario=flow.get("scenario", "completed"), auto_assign=True)
|
||||
ctx, problems = {}, []
|
||||
last_command = None
|
||||
timed_out: list = []
|
||||
try:
|
||||
for n, step in enumerate(flow["steps"], 1):
|
||||
resp = None
|
||||
if "mail" in step:
|
||||
m = _fill(step["mail"], ctx)
|
||||
d = h.mail(m["to"], m.get("text", ""), dmarc=m.get("dmarc", "pass"))
|
||||
resp = d.response if isinstance(d.response, dict) else {}
|
||||
elif "mode" in step:
|
||||
h.callback.run()
|
||||
elif "advance" in step:
|
||||
timed_out = h.advance(step["advance"])
|
||||
elif "pump" in step:
|
||||
results = h.master.pump()
|
||||
resp = results[-1][1] if results else {}
|
||||
elif "master" in step:
|
||||
cmd = _fill(step["master"], {**ctx, "callback": ""})
|
||||
case = {"address": ctx["case_address"], "key": ctx["case_key"]}
|
||||
if cmd["command"] == "send":
|
||||
last_command = h.master.send(case, cmd["in_reply_to"], cmd["body"])
|
||||
elif cmd["command"] == "cancel":
|
||||
last_command = h.master.cancel(case, cmd["handoff_id"])
|
||||
if isinstance(resp, dict):
|
||||
if resp.get("ticket_id"):
|
||||
ctx["case_id"] = resp["ticket_id"]
|
||||
if isinstance(resp.get("case"), dict):
|
||||
ctx["case_id"] = resp["case"]["address"].split("=", 1)[1].split("@")[0]
|
||||
ctx["case_address"], ctx["case_key"] = resp["case"]["address"], resp["case"]["key"]
|
||||
for k in ("message_ref", "handoff_id"):
|
||||
if resp.get(k):
|
||||
ctx[k] = resp[k]
|
||||
for key, want in step.get("expect", {}).items():
|
||||
got = _observe(h, key, resp, last_command, timed_out)
|
||||
if key == "master_received":
|
||||
got = {t: got.get(t, 0) for t in want}
|
||||
elif key == "last_mail_contains":
|
||||
got, want = (want in (got or "")), True
|
||||
if got != want:
|
||||
problems.append(f"step {n} {key}: got {got!r}, expected {want!r}")
|
||||
finally:
|
||||
h.close()
|
||||
return problems
|
||||
|
||||
|
||||
def _observe(h, key, resp, last_command, timed_out):
|
||||
if key == "response_status":
|
||||
return (resp or {}).get("status")
|
||||
if key == "statuses":
|
||||
return [m["status"] for hd in h.callback.handoffs.values() for m in hd.sent]
|
||||
if key == "relayed":
|
||||
return [m["status"] for m in h.master.of_type("handoff.status")]
|
||||
if key == "outbox_count":
|
||||
return len(h.outbox())
|
||||
if key == "last_mail_contains":
|
||||
mails = h.outbox()
|
||||
return mails[-1]["body"] if mails else None
|
||||
if key == "master_received":
|
||||
return dict(Counter(m["type"] for m in h.master.received))
|
||||
if key == "mode_received":
|
||||
return len(h.callback.received)
|
||||
if key == "mode_received_last":
|
||||
return h.callback.received[-1]["type"] if h.callback.received else None
|
||||
if key == "timed_out":
|
||||
return len(timed_out)
|
||||
if key == "case_address":
|
||||
return (resp or {}).get("case", {}).get("address")
|
||||
if key == "command_ok":
|
||||
return (last_command[1] or {}).get("ok")
|
||||
if key == "command_reason":
|
||||
return (last_command[1] or {}).get("reason")
|
||||
raise KeyError(f"unknown expectation {key!r}")
|
||||
|
||||
|
||||
def run_mode_corpus() -> Report:
|
||||
report = Report()
|
||||
check_messages(report)
|
||||
for flow in flow_fixtures():
|
||||
problems = run_flow(flow)
|
||||
if problems:
|
||||
report.failures.append((flow["id"], "; ".join(problems)))
|
||||
else:
|
||||
report.passed += 1
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
r = run_mode_corpus()
|
||||
print(json.dumps({"passed": r.passed, "failures": r.failures}, indent=2))
|
||||
39
tests/test_modes_conformance.py
Normal file
39
tests/test_modes_conformance.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Mode contract bundle (MAS-WP-0010 T02/T03/T07): vendored copies, fixtures, flows,
|
||||
and the gateway event schema checked against real gateway output."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mas_core.discovery import Response, discover
|
||||
from mas_gateway.core import Resolver, build_event, message_meta_from_rfc822
|
||||
from mas_modes import event_errors
|
||||
from mas_modes.runner import run_mode_corpus
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE = ROOT / "mas-modes-v0.1-spec"
|
||||
VENDORED = ROOT / "mas_modes"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel", sorted(str(p.relative_to(BUNDLE)) for p in BUNDLE.glob("*/*.json")))
|
||||
def test_vendored_copy_matches_bundle(rel):
|
||||
assert (BUNDLE / rel).read_bytes() == (VENDORED / rel).read_bytes()
|
||||
|
||||
|
||||
def test_mode_corpus():
|
||||
rep = run_mode_corpus()
|
||||
assert rep.ok, rep.failures
|
||||
assert rep.passed >= 170
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipient", [
|
||||
"ticket!callback@great-service.example", "ticket=GS-1+comments@great-service.example",
|
||||
"bernd.worsch@great-service.example", "ticket=PR42$4@great-service.example",
|
||||
"x@unknown.invalid"])
|
||||
def test_gateway_events_match_schema(recipient):
|
||||
raw = (b"Authentication-Results: great-service.example; dmarc=pass header.from=example.net\r\n"
|
||||
b"From: c@example.net\r\nTo: x@great-service.example\r\nSubject: s\r\n\r\nbody\r\n")
|
||||
offline = lambda host, **kw: discover(host, transport=lambda u, t: Response(404, {}, b"")) # noqa: E731
|
||||
resolver = Resolver.with_statute_files(
|
||||
[ROOT / "examples/statute/great-service.example/.well-known/mas"], discover_fn=offline)
|
||||
event = build_event(recipient, message_meta_from_rfc822(raw, "great-service.example"), resolver)
|
||||
assert event_errors(event) == [], event
|
||||
162
tests/test_modes_slave.py
Normal file
162
tests/test_modes_slave.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Email as slave (CONTRACT.md 3.2) under a stub outside master (MAS-WP-0010 T06)."""
|
||||
import pytest
|
||||
|
||||
from mas_modes.harness import Harness, ROOT
|
||||
|
||||
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
|
||||
D = "modes.example"
|
||||
|
||||
|
||||
def harness(**cfg):
|
||||
return Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}", **cfg}})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def h():
|
||||
harness_ = harness()
|
||||
yield harness_
|
||||
harness_.close()
|
||||
|
||||
|
||||
def intake(h, local="case", text="My order is late", **kw):
|
||||
d = h.mail(local, text, **kw)
|
||||
assert d.status == 202 and d.response["status"] == "intake-reported"
|
||||
[(code, body)] = h.master.pump()
|
||||
assert code == 200, body
|
||||
return body
|
||||
|
||||
|
||||
def test_master_issues_the_case_id(h):
|
||||
d = h.mail("case", "My order is late")
|
||||
[msg] = h.master.of_type("case.intake")
|
||||
assert msg["intake_ref"] == d.response["intake_ref"] and msg["event"]["mas"]["intake_for"] == "case"
|
||||
assert h.outbox() == [] # nothing sent before the master decides
|
||||
[(code, body)] = h.master.pump()
|
||||
assert body["case"] == {"address": f"case=EXT-100@{D}", "key": "case=ext-100"}
|
||||
d = h.mail("case=EXT-100", "more details")
|
||||
assert d.response["status"] == "reported"
|
||||
[m] = h.master.of_type("case.message")
|
||||
assert m["case"]["key"] == "case=ext-100" and m["event"]["message"]["text"].startswith("more details")
|
||||
assert h.outbox() == [] # still silent: no autonomous sending
|
||||
|
||||
|
||||
def test_email_never_invents_ids(h):
|
||||
h.mail("case", "hi")
|
||||
assert h.mail("case=EXT-999", "guess").response["status"] == "unknown-resource"
|
||||
assert h.master.of_type("case.message") == []
|
||||
|
||||
|
||||
def test_assign_refusals(h):
|
||||
h.master.auto_assign = False
|
||||
ref = h.mail("case", "hi").response["intake_ref"]
|
||||
assert h.master.assign("nope", "X1")[0] == 404
|
||||
assert h.master.assign(ref, "bad+id")[0] == 400
|
||||
assert h.master.assign(ref, "X1")[0] == 200
|
||||
assert h.master.assign(ref, "X2")[1]["error"] == "already-assigned"
|
||||
ref2 = h.mail("case", "again").response["intake_ref"]
|
||||
assert h.master.assign(ref2, "x1")[1]["error"] == "case-exists" # ids are case-insensitive here
|
||||
|
||||
|
||||
def test_send_only_on_instruction(h):
|
||||
body = intake(h)
|
||||
case, mref = body["case"], body["message_ref"]
|
||||
code, resp = h.master.send(case, mref, "We are looking into it.")
|
||||
assert (code, resp) == (200, {"ok": True, "sent": True})
|
||||
[mail] = h.outbox()
|
||||
assert mail["mail_from"] == f"case=EXT-100@{D}" and mail["mail_to"] == "customer@example.net"
|
||||
assert mail["subject"] == "Re: Help needed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kw, reason", [
|
||||
({"dmarc": "fail"}, "unauthenticated-sender"),
|
||||
({"dmarc": None}, "unauthenticated-sender"),
|
||||
({"headers": "Auto-Submitted: auto-replied\r\n"}, "automated-sender"),
|
||||
])
|
||||
def test_principle_10_refuses_master_sends(h, kw, reason):
|
||||
body = intake(h, **kw)
|
||||
code, resp = h.master.send(body["case"], body["message_ref"], "hello")
|
||||
assert code == 409 and resp["error"] == "refused" and resp["reason"] == reason
|
||||
assert h.outbox() == []
|
||||
|
||||
|
||||
def test_send_cap_and_unknown_message():
|
||||
h = Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}"}}, max_sent=0)
|
||||
try:
|
||||
body = intake(h)
|
||||
assert h.master.send(body["case"], body["message_ref"], "x")[1]["reason"] == "send-cap-reached"
|
||||
assert h.master.send(body["case"], "m-unknown", "x")[1]["reason"] == "unknown-message"
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_autonomous_policy_acknowledges():
|
||||
h = harness(autonomous_send=True)
|
||||
try:
|
||||
body = intake(h)
|
||||
assert body["acknowledged"] is True
|
||||
[ack] = h.outbox()
|
||||
assert "case EXT-100" in ack["body"] and ack["mail_from"] == f"case=EXT-100@{D}"
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_transfer_blocked_by_default(h):
|
||||
body = intake(h, local="case!callback")
|
||||
[req] = h.master.of_type("handoff.requested")
|
||||
assert req["forwarded"] is False and req["handoff_id"] == body["handoff_id"]
|
||||
[st] = h.master.of_type("handoff.status")
|
||||
assert (st["status"], st["reason"]) == ("refused", "blocked")
|
||||
assert h.callback.received == [] and h.outbox() == []
|
||||
|
||||
|
||||
def test_transfer_to_primed_webhook():
|
||||
h = harness(transfers={"webhook": "{callback}"})
|
||||
try:
|
||||
body = intake(h, local="case!callback", text="Phone: 123\nTime: now")
|
||||
assert h.master.of_type("handoff.requested")[0]["forwarded"] is True
|
||||
[req] = h.callback.received
|
||||
assert req["case"]["key"] == "case=ext-100" and req["details"] == {"phone": "123", "time": "now"}
|
||||
h.callback.run()
|
||||
relayed = h.master.of_type("handoff.status")
|
||||
assert [m["status"] for m in relayed] == ["scheduled", "running", "completed"]
|
||||
assert [m["seq"] for m in relayed] == [1, 2, 3]
|
||||
assert h.outbox() == [] # resuming is the master's job
|
||||
assert h.advance(10 ** 6) == [] # and so are timeouts
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_policy_from_assign_and_later_update(h):
|
||||
h.master.auto_assign = False
|
||||
ref = h.mail("case!callback", "Phone: 1\nTime: 2").response["intake_ref"]
|
||||
code, body = h.master.command("case.assign", intake_ref=ref, resource_id="A1",
|
||||
policy={"transfers": "block", "autonomous_send": False})
|
||||
assert code == 200 and h.master.of_type("handoff.requested")[0]["forwarded"] is False
|
||||
code, body = h.master.policy(body["case"], autonomous_send=True)
|
||||
assert body["policy"] == {"autonomous_send": True, "transfers": "block"}
|
||||
|
||||
|
||||
def test_negotiation_only_when_autonomous():
|
||||
for autonomous, mails in ((False, 0), (True, 2)): # acknowledgement + question
|
||||
h = harness(transfers={"webhook": "{callback}"}, autonomous_send=autonomous)
|
||||
try:
|
||||
h.callback.default = "negotiate"
|
||||
intake(h, local="case!callback", text="call me")
|
||||
h.callback.run()
|
||||
assert h.master.of_type("handoff.status")[-1]["status"] == "negotiating"
|
||||
assert len(h.outbox()) == mails
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_master_cancels_on_its_own_timeout():
|
||||
h = harness(transfers={"webhook": "{callback}"}, fields=("phone", "time", "scenario"))
|
||||
try:
|
||||
body = intake(h, local="case!callback", text="Phone: 1\nTime: 2\nScenario: silent")
|
||||
assert h.callback.run() == []
|
||||
code, resp = h.master.cancel(body["case"], body["handoff_id"])
|
||||
assert code == 200 and h.callback.received[-1]["type"] == "handoff.cancel"
|
||||
h.callback.run()
|
||||
assert h.master.of_type("handoff.status")[-1]["status"] == "cancelled"
|
||||
finally:
|
||||
h.close()
|
||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Mode reference implementation: contract, email master/slave roles, stubs"
|
||||
domain: communication
|
||||
repo: mail-address-semantics
|
||||
status: active
|
||||
status: finished
|
||||
owner: claude
|
||||
topic_slug: coulomb-social
|
||||
created: "2026-10-19"
|
||||
|
|
@ -63,7 +63,7 @@ Review the design with the founder before T02.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0010-T02
|
||||
status: progress
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e0af967d-15e8-50ad-8930-ad569224cddf"
|
||||
```
|
||||
|
|
@ -132,7 +132,7 @@ Add end-to-end tests against the callback stub for every scenario.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0010-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "fbef3fea-c0b6-5e79-bdf9-95bbf98c9bc6"
|
||||
```
|
||||
|
|
@ -154,7 +154,7 @@ Add end-to-end tests: stub master + email slave + callback stub.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0010-T07
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "0fcb541f-cab8-5014-aee0-4332fc7e0642"
|
||||
```
|
||||
|
|
@ -169,7 +169,7 @@ stubs). Update SCOPE.md.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0010-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "7d5dca10-184f-5b96-a280-b59dd8d77c60"
|
||||
```
|
||||
|
|
@ -178,3 +178,28 @@ Write a spec change proposal (SCP-8) and put it into the v0.2 draft:
|
|||
§7 says `!MODE` requests a handoff whose behavior the mode contract
|
||||
defines, and points to `mas-modes-v0.1-spec/`. No grammar or statute
|
||||
change. Add CHANGELOG and CONFORMANCE notes.
|
||||
|
||||
## Outcome
|
||||
|
||||
- **Contract** (`mas-modes-v0.1-spec/`, draft). Reviewed by the founder on
|
||||
2026-10-19: `mail.send` is replies only, and the status set is accepted.
|
||||
It has a message schema, the gateway event schema (T03), the transition
|
||||
table, 27 message fixtures, a statute and 13 scripted flows. One
|
||||
addition during implementation: `case.assign` may carry the initial
|
||||
policy, so a mode requested in the intake mail can be blocked or primed.
|
||||
- **Code** (`mas_modes/`). `email_mode.EmailMode` covers both roles: the
|
||||
master role runs over the ticket service through `MasterStore`, and the
|
||||
slave role over its own SQLite store. `mailer` holds the principle-10
|
||||
gate (now shared with the ticket service). `stubs/callback.py` has 10
|
||||
scenarios, and `stubs/master.py` assigns EXT-100, EXT-101, .... There is
|
||||
also `harness`, and `runner` (`mas test`: 172/172).
|
||||
- **Tests.** `tests/test_modes_master.py` (17), `test_modes_slave.py`
|
||||
(14), `test_modes_conformance.py` (12, including real gateway events
|
||||
validated against the schema).
|
||||
- **Spec.** SCP-8, plus v0.2 §7.1 "Handoffs" and notes in CHANGELOG and
|
||||
CONFORMANCE.
|
||||
- **Docs.** `docs/reuse-surface.md` and the bundle README.
|
||||
- **Not done.** No second mode stub: callback was enough to exercise the
|
||||
contract, and the contract text is mode-neutral. The reference email
|
||||
side has no authentication on its HTTP endpoints; it is for loopback and
|
||||
tests only, as documented.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue