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:
parent
a5310dd63c
commit
71c17fb5f9
14 changed files with 844 additions and 44 deletions
24
docs/mode-contract-proposals.md
Normal file
24
docs/mode-contract-proposals.md
Normal 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).
|
||||
|
|
@ -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
470
impl/js/src/email.mjs
Normal 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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue