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

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");
}