MAS-WP-0014 T01: independent JS email side for mode contract v0.1; MCP-1..6; flow a-008

impl/js/src/email.mjs (written from CONTRACT.md + FLOWS.md): both roles,
case store, handoffs, timeouts, one-cap outbox with holding, authorization.
Passes all 22 flows from the bundle; with the JS master all 12 slave
flows (no Python contract party). Writing it found six contract gaps,
folded into the draft as MCP-1..6 (acknowledgement, details convention,
resume names the outcome, opaque case key, error table, transfer
reporting); flows now check outcome tokens, not reference prose. A
mutation check found a missing flow: a-008 (per-type master check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
tegwick 2026-10-19 13:56:53 +02:00
parent a5310dd63c
commit 71c17fb5f9
14 changed files with 844 additions and 44 deletions

View file

@ -0,0 +1,24 @@
# Mode contract proposals (MCP)
Changes to `mas-modes-v0.1-spec/CONTRACT.md` while v0.1 is a draft. Each
one comes from evidence, like the address spec's SCP series.
## MCP-1 to MCP-6: found while writing the second email side (MAS-WP-0014)
**Evidence:** to write an email side from CONTRACT.md and FLOWS.md alone,
everything the flows check has to be in the text. Reading it that way
showed six places where the flows relied on the Python reference's
behavior, not on the contract.
| | Gap | Change (folded into the v0.1 draft) |
|---|---|---|
| MCP-1 | The flows expect a mail when the master role creates a case (`mails_sent: 1`), but §3.1 did not say email acknowledges | §3.1.1: email acknowledges a new case from its address. A `mode-not-enabled` refusal at creation goes into that mail, with no separate resume |
| MCP-2 | Asking for details and reading the answers relied on an unwritten `Field: value` convention | §3.1.4 defines details: lines, case-insensitive field names, first occurrence wins, and the question format `Field: ...` |
| MCP-3 | The flows checked the reference's English wording ("has taken place", "could not arrange") | §3.1.3: the resume mail MUST name the terminal status and the reason or detail. The flows now check those tokens |
| MCP-4 | `case.key` was "the equivalence key (§8)", but address spec v0.2 §8 makes the key format non-normative | §1: email chooses the key, equivalent addresses share it, and other parties treat it as opaque |
| MCP-5 | Errors such as `unknown-handoff` and `case-exists` were used but not defined | §5.1: an error table |
| MCP-6 | §3.2.4 said a blocked transfer produces only `handoff.requested`, but the reference also relays `refused (blocked)` (flow s-003) | §3.2.4 states both messages, which statuses are relayed, and the `seq` |
**Impact:** text and flow expectations only. The Python reference already
behaved this way, except that its resume mail now also names the status
(MCP-3).

View file

@ -9,6 +9,7 @@ Written from the specification texts and fixtures only, not ported from
| `src/statute.mjs` | stand-ins (§6.3), equivalence (§8), destination transforms (§12.1), length (§13) | `mas-v0.2-spec/conformance/statute-aware.json` |
| `src/signing.mjs` | peer authentication (mode contract §7) | `mas-modes-v0.1-spec/conformance/auth.json` |
| `src/master.mjs` | outside master for email's slave role (mode contract §3.2) | `mas-modes-v0.1-spec/conformance/flows.json`, slave setups, run against the Python email side |
| `src/email.mjs` | email side in both roles: case store, handoffs, timeouts, the outbox with one cap and holding, authorization (mode contract §3 to §7) | every flow in `flows.json`, with the Python gateway and callback stub as peers, alone or with `master.mjs` (MAS-WP-0014) |
```bash
cd impl/js && node --test # the three bundles, read in place
@ -21,5 +22,8 @@ generated local-parts per operation. Its first run found two gaps in the
spec text (SCP-9).
Nothing in Python imports this code. The flow runner starts `master.mjs`
as a separate process and drives it only through its loopback control API
(`mas_modes/external.py`).
and `email.mjs` as separate processes and drives them only through their
loopback control APIs (`mas_modes/external.py`). Writing `email.mjs` from
the contract text found six gaps (MCP-1 to MCP-6,
`docs/mode-contract-proposals.md`). Its mutation check found a missing
flow (a-008).

470
impl/js/src/email.mjs Normal file
View file

@ -0,0 +1,470 @@
// An email side for the MAS mode contract (mas-modes-v0.1-spec/CONTRACT.md),
// written from the contract and FLOWS.md, independent of mas_modes.email_mode.
// Both roles (section 3), peer authentication (7), principle 10 with one
// send cap that holds over the cap (6). Zero dependencies.
//
// node src/email.mjs --config FILE -> prints {"base_url": ...} on the first line
//
// Contract: POST /mas-events gateway events (unsigned on loopback unless event_peer)
// POST /mode-status/TOKEN handoff.status from the handoff's mode
// POST /master outside-master commands (slave role)
// Control: GET /control/outbox (loopback only)
// POST /control/advance {seconds} -> {timed_out: [...]}
import { createServer } from "node:http";
import { randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { sign, verify, AuthError } from "./signing.mjs";
import { parseLocalPart } from "./parser.mjs";
import { Statute, targetKey } from "./statute.mjs";
const VERSION = "0.1";
const BUNDLE = new URL("../../../mas-modes-v0.1-spec/", import.meta.url);
const T = JSON.parse(readFileSync(new URL("conformance/transitions.json", BUNDLE)));
const TERMINAL = new Set(T.terminal);
const allowed = (from, to) => (T.transitions[from ?? "(new)"] ?? []).includes(to);
const NOT_ENABLED = new Set(["InteractionModeNotEnabled", "ProfileNotEnabled"]);
const HELD_MAX_AGE = 7 * 86400;
const HELD_MAX_BYTES = 100 * 1024 * 1024;
const err = (status, error, extra = {}) => [status, { ok: false, error, ...extra }];
const newId = (p) => p + randomBytes(6).toString("hex");
/** Details: `Field: value` lines, field names ignoring case, first wins (CONTRACT 3.1.4). */
export function details(text, fields) {
const out = {};
for (const line of (text ?? "").split(/\r?\n/)) {
const m = /^\s*([A-Za-z_-]+)\s*:\s*(.+?)\s*$/.exec(line);
if (m && fields.includes(m[1].toLowerCase()) && !(m[1].toLowerCase() in out)) out[m[1].toLowerCase()] = m[2];
}
return out;
}
/** Principle 10 (CONTRACT 6): why email must not answer this message, or null. */
export function refusal(msg) {
const auto = (msg.auto_submitted ?? "no").toLowerCase() !== "no";
const bulk = ["bulk", "list", "junk"].includes((msg.precedence ?? "").toLowerCase());
if (!msg.from || auto || bulk) return "automated-sender";
if (msg.authentication?.dmarc !== "pass") return "unauthenticated-sender";
return null;
}
class Mailer {
constructor(maxSent, clock) {
this.maxSent = maxSent;
this.clock = clock;
this.outbox = [];
}
/** {reason: null} when sent; else {reason, held}. One cap for every sender. */
reply(caseRef, from, msg, subject, body) {
const why = refusal(msg);
if (why) return { reason: why, held: false };
const now = this.clock();
this.outbox = this.outbox.filter((m) => !m.held || now - m.created < HELD_MAX_AGE);
const mail = { case_ref: caseRef, mail_from: from, mail_to: msg.from,
subject: /^re:/i.test(subject) ? subject : `Re: ${subject}`, body,
in_reply_to: msg.message_id ?? null, created: now, held: null, sent: 0 };
const capped = this.maxSent !== null && this.maxSent !== undefined;
if (capped && this.outbox.filter((m) => m.sent).length >= this.maxSent) {
const heldBytes = this.outbox.filter((m) => m.held).reduce((n, m) => n + Buffer.byteLength(m.body), 0);
if (heldBytes + Buffer.byteLength(body) > HELD_MAX_BYTES) return { reason: "send-cap-reached", held: false };
this.outbox.push({ ...mail, held: "send-cap-reached" });
return { reason: "send-cap-reached", held: true };
}
this.outbox.push({ ...mail, sent: capped ? 1 : 0 }); // with a cap, an SMTP sink counts sends
return { reason: null, held: false };
}
}
const isLoopback = (a) => a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
export class EmailSide {
constructor(config) {
this.peerId = config.peer_id ?? "email";
this.keys = Object.fromEntries(Object.entries(config.keys).map(([p, k]) => [p, Buffer.from(k, "hex")]));
this.eventPeer = config.event_peer ?? null;
this.statute = new Statute(config.statute);
this.domain = config.statute.namespace;
this.roles = config.roles;
this.now = config.clock_start ?? 1_000_000;
this.mailer = new Mailer(config.max_sent ?? null, () => this.now);
this.baseUrl = "";
this.cases = new Map(); // key -> {address, id, type}
this.intakes = new Map(); // ref -> {type, messageRef, mode, assigned}
this.messages = new Map(); // ref -> {caseKey, msg}
this.handoffs = new Map(); // id -> handoff
this.policies = new Map(); // case key -> {autonomous_send, transfers}
this.seen = new Map(); // message id -> response (idempotency)
this.queue = [];
this.counter = 0;
for (const [type, cfg] of Object.entries(this.roles)) {
const peers = cfg.role === "master" ? Object.values(cfg.modes ?? {}).map((m) => m.peer) : [cfg.master_peer];
if (typeof cfg.transfers === "object") peers.push(cfg.transfers.peer);
for (const p of peers) if (!(p in this.keys)) throw new Error(`${type}: no key for peer ${p}`);
}
}
// -- helpers ------------------------------------------------------------------
caseFor(address) {
const local = address.slice(0, address.lastIndexOf("@"));
return { address, key: targetKey(parseLocalPart(local, "p2"), this.statute) };
}
send(url, peer, msg, onResult) {
this.queue.push({ url, peer, msg, onResult });
}
async flush() {
while (this.queue.length) {
const { url, peer, msg, onResult } = this.queue.shift();
const body = Buffer.from(JSON.stringify(msg));
let status = null;
try {
const res = await fetch(url, { method: "POST", body,
headers: { "Content-Type": "application/json", ...sign(body, this.peerId, this.keys[peer]) } });
status = res.status;
await res.text();
} catch { status = null; }
if (onResult) onResult(status);
}
}
message(type, kase, fields) {
const m = { mas_modes_version: VERSION, type, id: randomUUID().replaceAll("-", "") };
if (kase) m.case = kase;
for (const [k, v] of Object.entries(fields)) if (v !== null && v !== undefined) m[k] = v;
return m;
}
openHandoff(key) {
for (const h of this.handoffs.values()) if (h.caseKey === key && !TERMINAL.has(h.status)) return h;
return null;
}
// -- inbound mail (gateway events) ---------------------------------------------
event(ev) {
const mas = ev.mas ?? {};
const msg = ev.message ?? {};
if (!mas.valid || !mas.namespace_confirmed) return [422, { status: "not-handled" }];
const type = mas.kind === "resource" ? mas.resource_type : mas.intake_for;
const cfg = this.roles[type];
if (!cfg) return [404, { status: "not-handled" }];
return cfg.role === "master" ? this.masterEvent(ev, mas, msg, type, cfg) : this.slaveEvent(ev, mas, msg, type, cfg);
}
masterEvent(ev, mas, msg, type, cfg) {
let kase, created = false;
if (mas.kind === "resource") {
kase = this.cases.get(this.caseFor(`${mas.canonical_local_part}@${mas.domain}`).key);
if (!kase) return [202, { status: "unknown-resource" }];
} else {
const id = `js-${++this.counter}`;
const address = `${type}=${id}@${mas.domain}`;
kase = { ...this.caseFor(address), id, type };
this.cases.set(kase.key, kase);
created = true;
}
const ref = { address: kase.address, key: kase.key };
const failures = new Set((mas.failures ?? []).map((f) => f.code));
const mode = mas.interaction ?? null;
if (created) {
const lines = [`Your request is now case ${kase.id}.`, `Reply to ${kase.address}; no reference in the subject is needed.`];
if (mode && [...failures].some((f) => NOT_ENABLED.has(f))) {
lines.push(`The requested mode '${mode}' is not enabled here (refused: mode-not-enabled); we continue by email.`);
}
this.mailer.reply(kase.key, kase.address, msg, msg.subject || "Your request", lines.join("\n"));
}
const open = this.openHandoff(kase.key);
if (open) {
if (open.status === "negotiating") {
const d = details(msg.text, open.needs ?? []);
if (Object.keys(d).length) this.send(open.url, open.peer, this.message("handoff.update", ref, { handoff_id: open.id, details: d }));
}
return [200, { status: "correlated", case: ref, handoff_id: open.id }];
}
if (!mode) return [created ? 201 : 200, { status: created ? "created" : "correlated", case: ref }];
const endpoint = cfg.modes?.[mode];
const h = this.newHandoff("master", type, ref, mode, msg, endpoint, this.now + (cfg.timeout ?? 3600));
if ([...failures].some((f) => NOT_ENABLED.has(f))) {
this.status(h, "requested", {});
this.status(h, "refused", { reason: "mode-not-enabled" }, !created);
} else if (!endpoint) {
this.status(h, "requested", {});
this.status(h, "refused", { reason: "mode-unavailable" });
} else {
this.request(h, ref, details(msg.text, cfg.fields ?? ["phone", "time"]), msg);
}
return [created ? 201 : 200, { status: created ? "created" : "correlated", case: ref, handoff_id: h.id }];
}
newHandoff(role, type, ref, mode, msg, endpoint, deadline) {
const h = { id: newId("h-"), role, type, caseAddress: ref.address, caseKey: ref.key, mode, status: null,
seq: 0, relaySeq: 0, deadline, participant: msg, url: endpoint?.url ?? endpoint?.webhook ?? null,
peer: endpoint?.peer ?? null, token: randomBytes(24).toString("base64url"), needs: [] };
this.handoffs.set(h.id, h);
return h;
}
request(h, ref, d, msg) {
this.status(h, "requested", {});
const req = this.message("handoff.request", ref, { handoff_id: h.id, mode: h.mode, details: d,
status_url: `${this.baseUrl}/mode-status/${h.token}`, participant: msg.from ? { address: msg.from } : null });
this.send(h.url, h.peer, req, (status) => {
if (status === null || status >= 400) this.status(h, "refused", { reason: "mode-unavailable", detail: `mode endpoint answered ${status}` });
});
}
/** Record a status and react to it, per role (CONTRACT 3). */
status(h, status, info, notify = true) {
h.status = status;
if (info.needs) h.needs = info.needs;
const ref = { address: h.caseAddress, key: h.caseKey };
const subject = h.participant.subject || "Your request";
const ask = (send) => send(subject, [`To arrange the ${h.mode}, please reply to this message with:`,
...info.needs.map((n) => `${n[0].toUpperCase()}${n.slice(1)}: ...`)].join("\n"));
if (h.role === "master") {
if (status === "negotiating") ask((s, b) => this.mailer.reply(h.caseKey, h.caseAddress, h.participant, s, b));
else if (TERMINAL.has(status) && notify) {
const why = info.reason ?? info.detail;
this.mailer.reply(h.caseKey, h.caseAddress, h.participant, subject,
`Update on your ${h.mode} request. Outcome: ${status}${why ? ` (${why})` : ""}. We continue by email.`);
}
return;
}
const cfg = this.roles[h.type];
if (status !== "requested") {
h.relaySeq += 1;
this.send(cfg.master_url, cfg.master_peer, this.message("handoff.status", ref,
{ handoff_id: h.id, seq: h.relaySeq, status, ...info }));
}
if (status === "negotiating" && this.policies.get(h.caseKey).autonomous_send) {
ask((s, b) => this.mailer.reply(h.caseKey, h.caseAddress, h.participant, s, b));
}
}
slaveEvent(ev, mas, msg, type, cfg) {
if (mas.kind !== "resource") {
const ref = newId("i-");
const messageRef = newId("m-");
this.messages.set(messageRef, { caseKey: null, msg });
this.intakes.set(ref, { type, domain: mas.domain, messageRef, mode: mas.interaction ?? null, assigned: null });
this.send(cfg.master_url, cfg.master_peer, this.message("case.intake", null,
{ intake_ref: ref, resource_type: type, event: ev, requested_mode: mas.interaction ?? null }));
return [202, { status: "intake-reported", intake_ref: ref }];
}
const kase = this.cases.get(this.caseFor(`${mas.canonical_local_part}@${mas.domain}`).key);
if (!kase) return [202, { status: "unknown-resource" }];
const ref = { address: kase.address, key: kase.key };
const messageRef = newId("m-");
this.messages.set(messageRef, { caseKey: kase.key, msg });
this.send(cfg.master_url, cfg.master_peer, this.message("case.message", ref, { event: ev, message_ref: messageRef }));
const open = this.openHandoff(kase.key);
if (open?.status === "negotiating" && open.url) {
const d = details(msg.text, open.needs);
if (Object.keys(d).length) this.send(open.url, open.peer, this.message("handoff.update", ref, { handoff_id: open.id, details: d }));
} else if (!open && mas.interaction) {
this.transfer(type, cfg, ref, mas.interaction, msg);
}
return [200, { status: "reported", case: ref, message_ref: messageRef }];
}
transfer(type, cfg, ref, mode, msg) {
const t = this.policies.get(ref.key).transfers;
const webhook = typeof t === "object" ? t : null;
const h = this.newHandoff("slave", type, ref, mode, msg, webhook, null);
this.send(cfg.master_url, cfg.master_peer, this.message("handoff.requested", ref,
{ handoff_id: h.id, mode, forwarded: Boolean(webhook) }));
if (!webhook) {
this.status(h, "requested", {});
this.status(h, "refused", { reason: "blocked" });
} else {
this.request(h, ref, details(msg.text, cfg.fields ?? ["phone", "time"]), msg);
}
return h.id;
}
// -- statuses from modes -------------------------------------------------------
modeStatus(msg, peer, token) {
const h = [...this.handoffs.values()].find((x) => x.token === token);
if (!h) return err(404, "unknown-handoff");
if (peer !== h.peer) return err(403, "wrong-peer");
if (msg.mas_modes_version !== VERSION || msg.type !== "handoff.status" || !Number.isInteger(msg.seq)
|| typeof msg.status !== "string" || !msg.case) return err(400, "schema");
if (msg.handoff_id !== h.id || msg.case.key !== h.caseKey) return err(404, "unknown-handoff");
if (msg.seq <= h.seq) return [200, { ok: true, ignored: "stale" }];
if (msg.status === "timed-out" || !allowed(h.status, msg.status)) return err(409, "invalid-transition");
if (msg.status === "negotiating" && !Array.isArray(msg.needs)) return err(400, "schema");
h.seq = msg.seq;
const info = {};
for (const k of ["needs", "at", "outcome", "reason", "detail"]) if (k in msg) info[k] = msg[k];
this.status(h, msg.status, info);
return [200, { ok: true }];
}
advance(seconds) {
this.now += seconds;
const out = [];
for (const h of this.handoffs.values()) {
if (h.role !== "master" || h.status === null || TERMINAL.has(h.status) || h.deadline > this.now) continue;
this.status(h, "timed-out", {});
if (h.url) this.send(h.url, h.peer, this.message("handoff.cancel", { address: h.caseAddress, key: h.caseKey },
{ handoff_id: h.id, reason: "timed-out" }));
out.push(h.id);
}
return out;
}
// -- outside-master commands (slave role) ---------------------------------------
isMaster(type, peer) {
const cfg = this.roles[type];
return Boolean(cfg && cfg.role === "slave" && peer && peer === cfg.master_peer);
}
policyPeerProblem(policy) {
const t = policy?.transfers;
if (t && typeof t === "object" && !(t.peer in this.keys)) return err(400, "unknown-peer");
if (t !== undefined && t !== "block" && !(t && typeof t === "object" && t.webhook && t.peer)) return err(400, "schema");
return null;
}
master(msg, peer) {
if (!Object.keys(this.roles).some((t) => this.isMaster(t, peer))) return err(403, "wrong-peer");
if (msg.mas_modes_version !== VERSION || !msg.id || typeof msg.type !== "string") return err(400, "schema");
if (msg.type === "case.assign") return this.assign(msg, peer);
if (!msg.case?.key) return err(400, "schema");
const kase = this.cases.get(msg.case.key);
if (!kase) return err(404, "unknown-case");
if (!this.isMaster(kase.type, peer)) return err(403, "wrong-peer");
if (msg.type === "case.policy") {
const bad = this.policyPeerProblem(msg);
if (bad) return bad;
const pol = { ...this.policies.get(kase.key) };
for (const k of ["autonomous_send", "transfers"]) if (k in msg) pol[k] = msg[k];
this.policies.set(kase.key, pol);
return [200, { ok: true, policy: pol }];
}
if (msg.type === "mail.send") {
if (typeof msg.in_reply_to !== "string" || typeof msg.body !== "string") return err(400, "schema");
const m = this.messages.get(msg.in_reply_to);
if (!m || m.caseKey !== kase.key) return err(409, "refused", { reason: "unknown-message" });
const r = this.mailer.reply(kase.key, kase.address, m.msg, msg.subject || m.msg.subject || "Your request", msg.body);
if (r.reason === null) return [200, { ok: true, sent: true }];
return err(409, "refused", { reason: r.reason, ...(r.reason === "send-cap-reached" ? { held: r.held } : {}) });
}
if (msg.type === "handoff.cancel") {
const h = this.handoffs.get(msg.handoff_id);
if (!h || h.caseKey !== kase.key) return err(404, "unknown-handoff");
if (h.url && !TERMINAL.has(h.status)) {
this.send(h.url, h.peer, this.message("handoff.cancel", msg.case, { handoff_id: h.id, reason: msg.reason }));
}
return [200, { ok: true }];
}
return err(400, "schema");
}
assign(msg, peer) {
const it = this.intakes.get(msg.intake_ref);
if (it && !this.isMaster(it.type, peer)) return err(403, "wrong-peer");
const bad = this.policyPeerProblem(msg.policy);
if (bad) return bad;
if (!it) return err(404, "unknown-intake");
if (it.assigned !== null) return err(409, "already-assigned");
let kase;
try {
parseLocalPart(`${it.type}=${msg.resource_id}`, "p1");
kase = { ...this.caseFor(`${it.type}=${msg.resource_id}@${it.domain}`), id: msg.resource_id, type: it.type };
} catch {
return err(400, "invalid-resource-id");
}
if (this.cases.has(kase.key)) return err(409, "case-exists");
const cfg = this.roles[it.type];
this.cases.set(kase.key, kase);
this.policies.set(kase.key, { autonomous_send: cfg.autonomous_send ?? false, transfers: cfg.transfers ?? "block",
...(msg.policy ?? {}) });
it.assigned = msg.resource_id;
const m = this.messages.get(it.messageRef);
m.caseKey = kase.key;
const ref = { address: kase.address, key: kase.key };
const out = { ok: true, case: ref, message_ref: it.messageRef };
if (this.policies.get(kase.key).autonomous_send) {
out.acknowledged = this.mailer.reply(kase.key, kase.address, m.msg, m.msg.subject || "Your request",
`Your request is now case ${kase.id}.\nReply to ${kase.address} to add to it.`).reason === null;
}
if (it.mode) out.handoff_id = this.transfer(it.type, cfg, ref, it.mode, m.msg);
return [200, out];
}
// -- HTTP ----------------------------------------------------------------------
listen(port = 0) {
const server = createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks);
const reply = (status, obj) => {
const out = JSON.stringify(obj);
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(out) });
res.end(out);
};
const loop = isLoopback(req.socket.remoteAddress);
try {
if (req.url.startsWith("/control/")) {
if (!loop) return reply(403, { ok: false, error: "control is loopback-only" });
if (req.method === "GET" && req.url === "/control/outbox") return reply(200, this.mailer.outbox);
if (req.method === "POST" && req.url === "/control/advance") {
const out = this.advance(JSON.parse(raw.toString() || "{}").seconds ?? 0);
await this.flush();
return reply(200, { ok: true, timed_out: out });
}
return reply(404, { ok: false, error: "not-found" });
}
const signed = Object.keys(req.headers).some((h) => h.startsWith("mas-modes-"));
let peer = null;
const unsignedOk = req.url === "/mas-events" && this.eventPeer === null && loop;
if (signed || !unsignedOk) {
try {
peer = verify(req.headers, raw, this.keys);
} catch (e) {
if (e instanceof AuthError) return reply(e.status, { ok: false, error: e.code });
throw e;
}
}
let msg;
try { msg = JSON.parse(raw.toString("utf8")); } catch { return reply(400, { ok: false, error: "invalid-json" }); }
let out;
if (msg?.id && this.seen.has(msg.id) && req.url !== "/mas-events") out = this.seen.get(msg.id);
else if (req.url === "/mas-events") {
out = peer !== this.eventPeer ? err(403, "wrong-peer") : this.event(msg);
} else if (req.url.startsWith("/mode-status/")) {
out = this.modeStatus(msg, peer, req.url.slice("/mode-status/".length));
} else if (req.url === "/master") {
out = this.master(msg, peer);
} else {
out = err(404, "not-found");
}
if (msg?.id && req.url !== "/mas-events" && out[0] < 500) this.seen.set(msg.id, out);
await this.flush();
reply(...out);
} catch (e) {
reply(500, { ok: false, error: String(e) });
}
});
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => {
this.baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve(server);
}));
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const i = process.argv.indexOf("--config");
const config = JSON.parse(readFileSync(process.argv[i + 1], "utf8"));
const side = new EmailSide(config);
await side.listen(Number(config.port ?? 0));
process.stdout.write(JSON.stringify({ base_url: side.baseUrl }) + "\n");
}

View file

@ -11,8 +11,10 @@ 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).
resource address (`ticket=gs-1@D`), and identified by that address and a
`key`. Email chooses the key from the address-spec equivalence of the
target (§8), so equivalent addresses get the same key. Other parties
treat the key as opaque and echo it back unchanged (MCP-4).
- **Thread.** The durable record of a case: inbound and outbound mail, plus
every mode status.
- **Mode.** A communication channel other than email (`callback`, `chat`,
@ -35,7 +37,7 @@ message carries the envelope:
| `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`. |
| `case` | `{address, key}`: canonical resource address and email's opaque case key (§1). 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.
@ -53,20 +55,34 @@ message carries the envelope:
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.
1. Mail to an intake address creates a case. Email then sends the
participant one **acknowledgement** from the case address, so they learn
it (MCP-1); principle 10 applies. Mail to the case address is recorded
on that case.
On `!MODE` to an intake or resource address, email creates or finds the
case and sends `handoff.request` to the mode endpoint configured for
that resource type and mode. If the statute does not enable the mode,
email records `refused` (`mode-not-enabled`) itself. For a new case,
the acknowledgement says so and no separate resume mail follows. If no
endpoint is configured, or the endpoint refuses or cannot be reached,
email records `refused` (`mode-unavailable`) and resumes (item 3).
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`.
address, saying what happened. The mail MUST name the outcome: the
terminal status as written in §4 (`completed`, `timed-out`, ...) and,
when present, the `reason` or `detail` (MCP-3). Principle 10 (§6)
applies.
4. **Details** (MCP-2). A detail is a line `Field: value` in a mail body.
Field names are compared ignoring case, and the first occurrence wins.
Email reads the fields the role configuration names (`fields`) from the
mail that requests a handoff, and puts them in `handoff.request`'s
`details`. `negotiating` with `needs` makes email ask the participant
with one mail that lists each needed field on its own line as
`Field: ...`, with the field name capitalized. A later mail on the case
that supplies needed fields is sent to the mode as `handoff.update`.
### 3.2 Email as slave
@ -89,10 +105,15 @@ The outside master owns the control flow, case identifiers and timeouts.
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, "peer": ID}` (the peer must be known to email, §7.3). 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
`{"webhook": URL, "peer": ID}` (the peer must be known to email, §7.3).
With `block`, a `!MODE` request is reported as `handoff.requested` with
`"forwarded": false`, followed by one `handoff.status` `refused` with
reason `blocked`, and nothing else happens (MCP-6). With a webhook,
email sends `handoff.request` to that URL, reports `handoff.requested`
with `"forwarded": true`, and relays every status the mode sends (and
one it records itself, such as `mode-unavailable`) as `handoff.status`.
It numbers them with its own `seq`. Email's own `requested` is not
relayed: `handoff.requested` stands for it. Email 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`.
@ -155,6 +176,22 @@ A receiver MUST refuse a transition that the table does not allow
case. When `autonomous_send` is on, the response also has `acknowledged`
(bool). When the intake requested a mode, it has `handoff_id`.
### 5.1 Other errors
Besides §7.4, a receiver answers with these codes (MCP-5):
| Code | HTTP | Meaning |
|---|---|---|
| `schema` | 400 | the message does not validate against the schema |
| `invalid-resource-id` | 400 | `case.assign` id does not make a valid resource address |
| `unknown-handoff` | 404 | no such handoff at this URL, or the `handoff_id` / `case` do not match it |
| `unknown-case` | 404 | no case with this key |
| `unknown-intake` | 404 | no intake with this `intake_ref` |
| `invalid-transition` | 409 | the status may not follow the current one (§4) |
| `already-assigned` | 409 | the intake already has a case |
| `case-exists` | 409 | a case with the same key exists |
| `refused` | 409 | `mail.send` not sent, with `reason` (§5) |
## 6. Principle 10 in both roles
Email MUST NOT send mail to a sender that did not pass DMARC (as read

View file

@ -21,7 +21,9 @@ For each flow, start fresh:
`scenarios.json`, with `flow.scenario` as its default. It sends nothing
until a `mode` step.
3. **Outside master stub.** Peer `cms-1` records every message it gets,
and sends commands only in `master` steps.
and sends commands only in `master` steps. Other masters named in a
setup (`cms-2`) are only claimed in `inject` steps, signed with their
key from `peers`.
4. **Clock.** A controllable clock for the email side's timeouts. Only
`advance` steps move it. Signatures use real time.
5. **Send cap.** When `flow.max_sent` is set, the email side's send cap is

View file

@ -4,7 +4,8 @@
"peers": {
"callback-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d63616c6c6261636b31",
"cms-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d636d732d312d2d2d2d",
"gateway": "6d61732d6d6f6465732d76302e312d746573742d6b65792d676174657761792d2d"
"gateway": "6d61732d6d6f6465732d76302e312d746573742d6b65792d676174657761792d2d",
"cms-2": "6d61732d6d6f6465732d76302e312d746573742d6b65792d636d732d322d2d2d2d"
},
"setups": {
"master": {
@ -47,6 +48,22 @@
}
}
}
},
"two-masters": {
"roles": {
"case": {
"role": "slave",
"master_peer": "cms-1",
"autonomous_send": false,
"transfers": "block"
},
"ticket": {
"role": "slave",
"master_peer": "cms-2",
"autonomous_send": false,
"transfers": "block"
}
}
}
},
"flows": [
@ -74,7 +91,7 @@
"running",
"completed"
],
"last_mail_contains": "has taken place"
"last_mail_contains": "completed"
}
}
]
@ -122,7 +139,7 @@
"running",
"completed"
],
"last_mail_contains": "has taken place"
"last_mail_contains": "completed"
}
}
]
@ -175,7 +192,7 @@
"attempted",
"failed"
],
"last_mail_contains": "did not succeed"
"last_mail_contains": "failed"
}
}
]
@ -236,7 +253,7 @@
"expect": {
"mode_received_last": "handoff.cancel",
"mails_sent": 2,
"last_mail_contains": "could not arrange"
"last_mail_contains": "timed-out"
}
},
{
@ -846,6 +863,67 @@
}
}
]
},
{
"id": "a-008",
"setup": "two-masters",
"title": "a master commands only its own resource types",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
},
"expect": {
"master_received": {
"case.intake": 1
}
}
},
{
"inject": {
"endpoint": "master",
"as": "cms-2",
"message": {
"type": "case.assign",
"intake_ref": "{intake_ref}",
"resource_id": "OTHER-1"
}
},
"expect": {
"http_status": 403,
"error": "wrong-peer"
}
},
{
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"response_ok": true,
"case_address": "case=EXT-100@modes.example"
}
},
{
"inject": {
"endpoint": "master",
"as": "cms-2",
"message": {
"type": "mail.send",
"case": "current",
"in_reply_to": "{message_ref}",
"body": "x"
}
},
"expect": {
"http_status": 403,
"error": "wrong-peer",
"mails_sent": 0
}
}
]
}
]
}

View file

@ -21,7 +21,9 @@ For each flow, start fresh:
`scenarios.json`, with `flow.scenario` as its default. It sends nothing
until a `mode` step.
3. **Outside master stub.** Peer `cms-1` records every message it gets,
and sends commands only in `master` steps.
and sends commands only in `master` steps. Other masters named in a
setup (`cms-2`) are only claimed in `inject` steps, signed with their
key from `peers`.
4. **Clock.** A controllable clock for the email side's timeouts. Only
`advance` steps move it. Signatures use real time.
5. **Send cap.** When `flow.max_sent` is set, the email side's send cap is

View file

@ -4,7 +4,8 @@
"peers": {
"callback-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d63616c6c6261636b31",
"cms-1": "6d61732d6d6f6465732d76302e312d746573742d6b65792d636d732d312d2d2d2d",
"gateway": "6d61732d6d6f6465732d76302e312d746573742d6b65792d676174657761792d2d"
"gateway": "6d61732d6d6f6465732d76302e312d746573742d6b65792d676174657761792d2d",
"cms-2": "6d61732d6d6f6465732d76302e312d746573742d6b65792d636d732d322d2d2d2d"
},
"setups": {
"master": {
@ -47,6 +48,22 @@
}
}
}
},
"two-masters": {
"roles": {
"case": {
"role": "slave",
"master_peer": "cms-1",
"autonomous_send": false,
"transfers": "block"
},
"ticket": {
"role": "slave",
"master_peer": "cms-2",
"autonomous_send": false,
"transfers": "block"
}
}
}
},
"flows": [
@ -74,7 +91,7 @@
"running",
"completed"
],
"last_mail_contains": "has taken place"
"last_mail_contains": "completed"
}
}
]
@ -122,7 +139,7 @@
"running",
"completed"
],
"last_mail_contains": "has taken place"
"last_mail_contains": "completed"
}
}
]
@ -175,7 +192,7 @@
"attempted",
"failed"
],
"last_mail_contains": "did not succeed"
"last_mail_contains": "failed"
}
}
]
@ -236,7 +253,7 @@
"expect": {
"mode_received_last": "handoff.cancel",
"mails_sent": 2,
"last_mail_contains": "could not arrange"
"last_mail_contains": "timed-out"
}
},
{
@ -846,6 +863,67 @@
}
}
]
},
{
"id": "a-008",
"setup": "two-masters",
"title": "a master commands only its own resource types",
"scenario": "completed",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
},
"expect": {
"master_received": {
"case.intake": 1
}
}
},
{
"inject": {
"endpoint": "master",
"as": "cms-2",
"message": {
"type": "case.assign",
"intake_ref": "{intake_ref}",
"resource_id": "OTHER-1"
}
},
"expect": {
"http_status": 403,
"error": "wrong-peer"
}
},
{
"master": {
"command": "assign",
"resource_id": "EXT-100"
},
"expect": {
"response_ok": true,
"case_address": "case=EXT-100@modes.example"
}
},
{
"inject": {
"endpoint": "master",
"as": "cms-2",
"message": {
"type": "mail.send",
"case": "current",
"in_reply_to": "{message_ref}",
"body": "x"
}
},
"expect": {
"http_status": 403,
"error": "wrong-peer",
"mails_sent": 0
}
}
]
}
]
}

View file

@ -106,7 +106,8 @@ def _resume_text(mode: str, status: str, info: dict) -> str:
"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."
outcome = f"Outcome: {status}" + (f" ({why})" if why else "")
return f"{texts[status]} We continue by email: reply to this message.\n{outcome}"
class EmailMode:

View file

@ -76,3 +76,34 @@ class ExternalMaster:
def close(self):
self.proc.terminate()
self.proc.wait(timeout=10)
JS_EMAIL = ROOT / "impl/js/src/email.mjs"
class ExternalEmail:
"""An email side in another process (e.g. impl/js/src/email.mjs), configured
with a flow setup and driven through its control API."""
def __init__(self, argv: list[str], config: dict, scratch: Path):
cfg = scratch / f"email-{id(self)}.json"
cfg.write_text(json.dumps(config))
self.proc = subprocess.Popen([*argv, "--config", str(cfg)], stdout=subprocess.PIPE, text=True)
self.base_url = json.loads(self.proc.stdout.readline())["base_url"]
@classmethod
def js(cls, config: dict, scratch: Path) -> "ExternalEmail":
return cls(["node", str(JS_EMAIL)], config, scratch)
def outbox(self) -> list[dict]:
return _call(self.base_url + "/control/outbox")
def tick(self) -> list[str]:
return self.advance(0)
def advance(self, seconds: float) -> list[str]:
return _call(self.base_url + "/control/advance", {"seconds": seconds})["timed_out"]
def close(self):
self.proc.terminate()
self.proc.wait(timeout=10)

View file

@ -10,8 +10,10 @@ with email: `callback-1` (the stub) and `cms-1` (the stub master), plus
from __future__ import annotations
import json
import secrets
import sys
import tempfile
from pathlib import Path
from typing import Optional
@ -52,9 +54,13 @@ class Harness:
def __init__(self, statute_file, roles: dict, scenario: str = "completed",
auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None,
sign_events: bool = False, keys: Optional[dict] = None, master_factory=None):
sign_events: bool = False, keys: Optional[dict] = None, master_factory=None,
email_factory=None):
"""master_factory(key) -> an outside master with StubMaster's interface and
an `events_url` (e.g. `ExternalMaster.js`); default: the Python stub."""
an `events_url` (e.g. `ExternalMaster.js`); default: the Python stub.
email_factory(config, scratch_dir) -> an email side with `base_url`,
`outbox()`, `advance()` and `close()` (e.g. `ExternalEmail.js`); default:
`EmailMode` over the reference ticket service."""
self.resolver = Resolver.with_statute_files([statute_file])
[self.domain] = self.resolver.pinned
self.clock = FakeClock()
@ -77,7 +83,22 @@ class Harness:
return v
defaults = {"master": {"modes": {"callback": {"url": "{callback}", "peer": "callback-1"}}},
"slave": {"master_url": "{master}", "master_peer": "cms-1"}}
configs = {t: RoleConfig(**fill({**defaults[c["role"]], **c})) for t, c in roles.items()}
filled = {t: fill({**defaults[c["role"]], **c}) for t, c in roles.items()}
self.external = email_factory is not None
if self.external:
self._scratch = tempfile.TemporaryDirectory()
config = {"peer_id": "email", "keys": {p: k.hex() for p, k in self.keys.items()},
"event_peer": "gateway" if sign_events else None, "max_sent": max_sent,
"statute": json.loads(Path(statute_file).read_text()),
"roles": {t: {**c, "fields": list(c.get("fields", ("phone", "time")))}
for t, c in filled.items()}}
self.email = email_factory(config, Path(self._scratch.name))
self.tickets = None
self.signer = ("gateway", self.keys["gateway"]) if sign_events else None
self.master.email_url = self.email.base_url + "/master"
self.app_url = self.email.base_url + "/mas-events"
return
configs = {t: RoleConfig(**c) for t, c in filled.items()}
# One outbound path for both roles. SMTP is a sink when a cap is under test,
# since only mail actually sent counts against it.
self.sent: list = []
@ -104,6 +125,9 @@ class Harness:
def close(self):
for s in self._servers:
s.shutdown()
if self.external:
self.email.close()
self._scratch.cleanup()
if hasattr(self.master, "close"):
self.master.close()
@ -122,12 +146,15 @@ class Harness:
return d
def advance(self, seconds: float):
if self.external:
return self.email.advance(seconds)
self.clock.t += seconds
return self.email.tick()
def outbox(self) -> list[dict]:
"""Every mail email decided to send, in either role (held mail excluded)."""
return [m for m in self.mailer.outbox() if m["held"] is None]
mails = self.email.outbox() if self.external else self.mailer.outbox()
return [m for m in mails if m["held"] is None]
def thread(self, ticket_id: str) -> list[str]:
"""Mode entries recorded in a master-role ticket thread."""

View file

@ -206,7 +206,7 @@ def _master(h, cmd, ctx):
raise KeyError(f"unknown master command {c!r}")
def run_flow(flow: dict, doc: Optional[dict] = None, master_factory=None) -> list[str]:
def run_flow(flow: dict, doc: Optional[dict] = None, master_factory=None, email_factory=None) -> list[str]:
"""Run one flow from the bundle (FLOWS.md). Returns the problems found.
master_factory swaps in another outside master (e.g. the JS one)."""
doc = doc or _load("conformance/flows.json")
@ -217,7 +217,7 @@ def run_flow(flow: dict, doc: Optional[dict] = None, master_factory=None) -> lis
with resources.as_file(resources.files(__package__).joinpath("conformance", doc["statute"])) as st:
h = Harness(st, _roles(doc["setups"][flow["setup"]]), scenario=flow["scenario"],
auto_assign=False, max_sent=flow.get("max_sent"), keys=keys,
master_factory=master_factory)
master_factory=master_factory, email_factory=email_factory)
ctx: dict = {}
problems = []
try:
@ -295,11 +295,11 @@ def _observe(h, key, event, response):
raise KeyError(f"unknown expectation {key!r}")
def run_mode_corpus(master_factory=None, roles=("master", "slave")) -> Report:
def run_mode_corpus(master_factory=None, roles=("master", "slave"), email_factory=None) -> Report:
"""All fixtures and flows. With master_factory, only the flows of the given
setup roles run, against that outside master."""
report = Report()
if master_factory is None:
if master_factory is None and email_factory is None:
check_messages(report)
check_auth(report)
doc = _load("conformance/flows.json")
@ -307,7 +307,7 @@ def run_mode_corpus(master_factory=None, roles=("master", "slave")) -> Report:
setup_roles = {c["role"] for c in doc["setups"][flow["setup"]]["roles"].values()}
if not setup_roles & set(roles):
continue
problems = run_flow(flow, doc, master_factory)
problems = run_flow(flow, doc, master_factory, email_factory)
if problems:
report.failures.append((flow["id"], "; ".join(problems)))
else:

View file

@ -21,4 +21,21 @@ def test_js_outside_master_passes_the_slave_flows():
from mas_modes.runner import run_mode_corpus
rep = run_mode_corpus(master_factory=ExternalMaster.js, roles=("slave",))
assert rep.ok, rep.failures
assert rep.passed == 11
assert rep.passed == 12
def test_js_email_side_passes_every_flow():
from mas_modes.external import ExternalEmail
from mas_modes.runner import run_mode_corpus
rep = run_mode_corpus(email_factory=ExternalEmail.js)
assert rep.ok, rep.failures
assert rep.passed == 22
def test_js_email_and_js_master_together():
# Neither contract party is Python: only the gateway and the callback stub are.
from mas_modes.external import ExternalEmail, ExternalMaster
from mas_modes.runner import run_mode_corpus
rep = run_mode_corpus(email_factory=ExternalEmail.js, master_factory=ExternalMaster.js, roles=("slave",))
assert rep.ok, rep.failures
assert rep.passed == 12

View file

@ -4,7 +4,7 @@ type: workplan
title: "Finalize mode contract v0.1: an independent email side"
domain: communication
repo: mail-address-semantics
status: backlog
status: active
owner: claude
topic_slug: coulomb-social
created: "2026-10-19"
@ -25,7 +25,7 @@ exists only in Python (`mas_modes.email_mode`).
```task
id: MAS-WP-0014-T01
status: wait
status: done
priority: low
state_hub_task_id: "851411f7-ec90-5184-90a7-51a75eb3d856"
```
@ -41,10 +41,39 @@ the findings into CONTRACT.md as proposals, as SCP-9 did for v0.2.
```task
id: MAS-WP-0014-T02
status: wait
status: progress
priority: low
state_hub_task_id: "63e4028d-ff57-5855-92d5-fa6ece0382c2"
```
With the findings folded in, ask the founder to declare mode contract v0.1
final. Then remove "draft" from the bundle and v0.2 §7.1.
## Outcome of T01 (2026-10-19)
- **Founder interview.** An independent email side in JS. Findings are
reported before any decision to declare the contract final.
- **Findings, from writing it from the text alone.** MCP-1 to MCP-6
(`docs/mode-contract-proposals.md`), now folded into the v0.1 draft:
1. a new case is acknowledged by mail;
2. the details convention (`Field: value`);
3. resume mails name the status and reason, and the flows now check
those tokens, not the reference's prose;
4. `case.key` is opaque;
5. an error table;
6. what a blocked or forwarded transfer reports and relays.
- **`impl/js/src/email.mjs`.** Both roles, its own case store, handoffs,
timeouts, an outbox with one cap and holding, and authorization. Zero
dependencies; it reuses only `impl/js`'s own parser, statute rules and
signing, plus the bundle's transition table.
- It passes all 22 flows from the bundle, with the Python gateway and
callback stub as peers.
- Together with the JS master it passes all 12 slave-setup flows, so
neither contract party is Python.
- With a wrong key it fails all of them.
- **Test-case gap.** Mutation checks on 5 authorization and timeout rules
showed that a-002 could not catch a missing per-type master check. Flow
**a-008** was added (two slave types, two masters). Python and JS both
pass it, and it catches the mutation.
- **Tests.** `tests/test_js_impl.py`: JS email, all flows; JS email with JS
master. `mas test`: mode corpus 196/196.