From 71c17fb5f9544e32bf19114132c5f5bce676996b Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 13:56:53 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd --- docs/mode-contract-proposals.md | 24 + impl/js/README.md | 8 +- impl/js/src/email.mjs | 470 ++++++++++++++++++ mas-modes-v0.1-spec/CONTRACT.md | 69 ++- mas-modes-v0.1-spec/conformance/FLOWS.md | 4 +- mas-modes-v0.1-spec/conformance/flows.json | 88 +++- mas_modes/conformance/FLOWS.md | 4 +- mas_modes/conformance/flows.json | 88 +++- mas_modes/email_mode.py | 3 +- mas_modes/external.py | 31 ++ mas_modes/harness.py | 35 +- mas_modes/runner.py | 10 +- tests/test_js_impl.py | 19 +- .../MAS-WP-0014-finalize-mode-contract.md | 35 +- 14 files changed, 844 insertions(+), 44 deletions(-) create mode 100644 docs/mode-contract-proposals.md create mode 100644 impl/js/src/email.mjs diff --git a/docs/mode-contract-proposals.md b/docs/mode-contract-proposals.md new file mode 100644 index 0000000..b2ca35c --- /dev/null +++ b/docs/mode-contract-proposals.md @@ -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). diff --git a/impl/js/README.md b/impl/js/README.md index c2d5f52..1a6bb44 100644 --- a/impl/js/README.md +++ b/impl/js/README.md @@ -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). diff --git a/impl/js/src/email.mjs b/impl/js/src/email.mjs new file mode 100644 index 0000000..e850687 --- /dev/null +++ b/impl/js/src/email.mjs @@ -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"); +} diff --git a/mas-modes-v0.1-spec/CONTRACT.md b/mas-modes-v0.1-spec/CONTRACT.md index 3bfc41d..06e94ac 100644 --- a/mas-modes-v0.1-spec/CONTRACT.md +++ b/mas-modes-v0.1-spec/CONTRACT.md @@ -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 diff --git a/mas-modes-v0.1-spec/conformance/FLOWS.md b/mas-modes-v0.1-spec/conformance/FLOWS.md index 18065f3..b47beab 100644 --- a/mas-modes-v0.1-spec/conformance/FLOWS.md +++ b/mas-modes-v0.1-spec/conformance/FLOWS.md @@ -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 diff --git a/mas-modes-v0.1-spec/conformance/flows.json b/mas-modes-v0.1-spec/conformance/flows.json index 478f1d9..b9a8ea1 100644 --- a/mas-modes-v0.1-spec/conformance/flows.json +++ b/mas-modes-v0.1-spec/conformance/flows.json @@ -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 + } + } + ] } ] } diff --git a/mas_modes/conformance/FLOWS.md b/mas_modes/conformance/FLOWS.md index 18065f3..b47beab 100644 --- a/mas_modes/conformance/FLOWS.md +++ b/mas_modes/conformance/FLOWS.md @@ -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 diff --git a/mas_modes/conformance/flows.json b/mas_modes/conformance/flows.json index 478f1d9..b9a8ea1 100644 --- a/mas_modes/conformance/flows.json +++ b/mas_modes/conformance/flows.json @@ -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 + } + } + ] } ] } diff --git a/mas_modes/email_mode.py b/mas_modes/email_mode.py index 86a8e81..b180cda 100644 --- a/mas_modes/email_mode.py +++ b/mas_modes/email_mode.py @@ -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: diff --git a/mas_modes/external.py b/mas_modes/external.py index 46669d7..85618b9 100644 --- a/mas_modes/external.py +++ b/mas_modes/external.py @@ -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) diff --git a/mas_modes/harness.py b/mas_modes/harness.py index 15eeda6..c2e939d 100644 --- a/mas_modes/harness.py +++ b/mas_modes/harness.py @@ -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.""" diff --git a/mas_modes/runner.py b/mas_modes/runner.py index 735bf08..8b8026a 100644 --- a/mas_modes/runner.py +++ b/mas_modes/runner.py @@ -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: diff --git a/tests/test_js_impl.py b/tests/test_js_impl.py index 64314aa..7830bea 100644 --- a/tests/test_js_impl.py +++ b/tests/test_js_impl.py @@ -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 diff --git a/workplans/MAS-WP-0014-finalize-mode-contract.md b/workplans/MAS-WP-0014-finalize-mode-contract.md index 0299d33..d09a19d 100644 --- a/workplans/MAS-WP-0014-finalize-mode-contract.md +++ b/workplans/MAS-WP-0014-finalize-mode-contract.md @@ -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.