MAS-WP-0009 T01: independent JS implementation (impl/js), differential test, SCP-9
impl/js (Node, zero deps, written from the specs): parser, v0.2 statute-aware rules, signing, outside master. Passes addresses.json, statute-aware.json and auth.json unchanged; its master passes the 11 slave-setup mode flows against the Python email side (ExternalMaster). Differential testing vs mas_core found two unstated stand-in rules, now SCP-9 in the v0.2 draft with fixtures si-011..013, dt-109; ABNF comments no longer say v0.1. Runner: external masters, step errors fail the flow. 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
ecb1ba47f6
commit
a841ceec73
23 changed files with 938 additions and 49 deletions
|
|
@ -327,6 +327,33 @@ implement handoffs get a contract to conform to.
|
|||
|
||||
---
|
||||
|
||||
## SCP-9: stand-ins stand for absent operators
|
||||
|
||||
**Section:** §6.3 Stand-ins.
|
||||
|
||||
**Evidence:** MAS-WP-0009. A JavaScript implementation, written only from
|
||||
the spec text, was compared with `mas_core` over 3000 generated
|
||||
local-parts. They disagreed on two inputs that §6.3 leaves open:
|
||||
|
||||
- `q!---callback`: read literally ("ends in `---MODE`"), the interaction
|
||||
stand-in applies even though the local-part already has `!`. That gives
|
||||
two interactions, which is invalid. `mas_core` reads it canonically.
|
||||
- `ticket---`, `ticket---chat` (`chat` not enabled): read literally
|
||||
("split at the first `--`"), this is `ticket=-` / `ticket=-chat`.
|
||||
`mas_core` does not read a resource stand-in whose id begins with `-`,
|
||||
because three dashes signal the interaction stand-in.
|
||||
|
||||
**Change (applied to the v0.2 draft):** the interaction stand-in needs a
|
||||
local-part with no `!`. The resource stand-in needs an `ID` that does not
|
||||
begin with `-`. Identifiers beginning with `-` cannot be written with the
|
||||
resource stand-in, so generators refuse. New table rows; fixtures si-011
|
||||
to si-013 and dt-109.
|
||||
|
||||
**Impact:** clarifies edge cases. Neither implementation changes behavior
|
||||
on any earlier fixture.
|
||||
|
||||
---
|
||||
|
||||
## Lower-priority gaps (no proposal yet)
|
||||
|
||||
Recorded in the review, not urgent:
|
||||
|
|
|
|||
25
impl/js/README.md
Normal file
25
impl/js/README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# mas-js: second, independent MAS implementation
|
||||
|
||||
Written from the specification texts and fixtures only, not ported from
|
||||
`mas_core` (MAS-WP-0009). It uses Node 20+ and has zero dependencies.
|
||||
|
||||
| Module | Covers | Checked by |
|
||||
|---|---|---|
|
||||
| `src/parser.mjs` | canonical grammar, P1/P2 (address spec §4) | `mas-v0.2-spec/conformance/addresses.json` |
|
||||
| `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 |
|
||||
|
||||
```bash
|
||||
cd impl/js && node --test # the three bundles, read in place
|
||||
cd ../.. && .venv/bin/pytest tests/test_js_impl.py tests/test_differential.py
|
||||
```
|
||||
|
||||
`src/batch.mjs` evaluates parse, interpret and destination cases in bulk.
|
||||
`tests/test_differential.py` compares it with `mas_core` over 1500
|
||||
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`).
|
||||
9
impl/js/package.json
Normal file
9
impl/js/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "mas-js",
|
||||
"version": "0.2.0-dev",
|
||||
"description": "Second, independent MAS implementation (MAS-WP-0009): address parser, v0.2 statute-aware rules, mode-contract outside master. Zero dependencies.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"engines": {"node": ">=20"},
|
||||
"scripts": {"test": "node --test"}
|
||||
}
|
||||
31
impl/js/src/batch.mjs
Normal file
31
impl/js/src/batch.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Batch evaluator for differential testing against another implementation.
|
||||
// stdin: JSON {statutes: {name: doc}, cases: [{op, ...}]}; stdout: JSON results.
|
||||
// {op: "parse", local, profile} -> {ok, parsed} | {ok: false}
|
||||
// {op: "interpret", local, statute} -> {ok, canonical, stand_ins, key} | {ok: false}
|
||||
// {op: "dest", local, statute, needs, lossy} -> {ok, text} | {ok: false}
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseLocalPart } from "./parser.mjs";
|
||||
import { Statute, interpret, targetKey, forDestination } from "./statute.mjs";
|
||||
|
||||
const input = JSON.parse(readFileSync(0, "utf8"));
|
||||
const statutes = Object.fromEntries(Object.entries(input.statutes ?? {}).map(([k, d]) => [k, new Statute(d)]));
|
||||
const out = input.cases.map((c) => {
|
||||
try {
|
||||
if (c.op === "parse") {
|
||||
const { required_profile, generator_allowed, ...parsed } = parseLocalPart(c.local, c.profile);
|
||||
return { ok: true, parsed };
|
||||
}
|
||||
if (c.op === "interpret") {
|
||||
const st = statutes[c.statute];
|
||||
const r = interpret(c.local, st);
|
||||
return { ok: true, canonical: r.canonical, stand_ins: r.stand_ins, key: targetKey(r.parsed, st) };
|
||||
}
|
||||
if (c.op === "dest") {
|
||||
return { ok: true, text: forDestination(c.local, statutes[c.statute], c.needs, { allowLossy: c.lossy }) };
|
||||
}
|
||||
return { ok: false, error: "unknown op" };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.code ?? e.reason ?? String(e) };
|
||||
}
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
116
impl/js/src/master.mjs
Normal file
116
impl/js/src/master.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// An outside master (slave role, CONTRACT.md section 3.2), written from the
|
||||
// mode contract. It receives email's signed events, and sends signed
|
||||
// commands when told to through a loopback-only control API, so a flow
|
||||
// runner in any language can drive it.
|
||||
//
|
||||
// node src/master.mjs --peer-id cms-1 --key-hex HEX [--email-url URL] [--port 0]
|
||||
// -> prints {"events_url": ..., "control_url": ...} on the first stdout line
|
||||
//
|
||||
// Contract: POST /events (signed by email)
|
||||
// Control: POST /control/config {email_url}
|
||||
// POST /control/command {type, case?, ...fields} -> {status, body}
|
||||
// GET /control/received -> [messages]
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sign, verify, AuthError } from "./signing.mjs";
|
||||
|
||||
const CONTRACT_VERSION = "0.1";
|
||||
|
||||
export class OutsideMaster {
|
||||
constructor({ peerId = "cms-1", emailPeer = "email", key, emailUrl = null }) {
|
||||
this.peerId = peerId;
|
||||
this.emailPeer = emailPeer;
|
||||
this.keys = { [emailPeer]: key };
|
||||
this.emailUrl = emailUrl;
|
||||
this.received = [];
|
||||
this.seen = new Map(); // idempotency: message id -> response
|
||||
}
|
||||
|
||||
/** Contract endpoint: one signed message from email. Returns [status, body]. */
|
||||
receive(headers, raw) {
|
||||
let peer;
|
||||
try {
|
||||
peer = verify(headers, raw, this.keys);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return [e.status, { ok: false, error: e.code }];
|
||||
throw e;
|
||||
}
|
||||
if (peer !== this.emailPeer) return [403, { ok: false, error: "wrong-peer" }];
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString("utf8"));
|
||||
} catch {
|
||||
return [400, { ok: false, error: "invalid-json" }];
|
||||
}
|
||||
if (msg.mas_modes_version !== CONTRACT_VERSION || typeof msg.type !== "string" || !msg.id) {
|
||||
return [400, { ok: false, error: "schema" }];
|
||||
}
|
||||
if (this.seen.has(msg.id)) return this.seen.get(msg.id);
|
||||
this.received.push(msg);
|
||||
const out = [200, { ok: true }];
|
||||
this.seen.set(msg.id, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Send one signed command to email. Returns [status, body]. */
|
||||
async command(type, fields = {}) {
|
||||
const msg = { mas_modes_version: CONTRACT_VERSION, type, id: randomUUID().replaceAll("-", ""), ...fields };
|
||||
for (const k of Object.keys(msg)) if (msg[k] === null || msg[k] === undefined) delete msg[k];
|
||||
const body = Buffer.from(JSON.stringify(msg));
|
||||
const res = await fetch(this.emailUrl, {
|
||||
method: "POST", body,
|
||||
headers: { "Content-Type": "application/json", ...sign(body, this.peerId, this.keys[this.emailPeer]) },
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed = null;
|
||||
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
|
||||
return [res.status, parsed];
|
||||
}
|
||||
}
|
||||
|
||||
const isLoopback = (addr) => addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
||||
|
||||
export function serve(master, 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 send = (status, obj) => {
|
||||
const out = JSON.stringify(obj);
|
||||
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(out) });
|
||||
res.end(out);
|
||||
};
|
||||
try {
|
||||
if (req.method === "POST" && req.url === "/events") return send(...master.receive(req.headers, raw));
|
||||
if (!req.url.startsWith("/control/")) return send(404, { ok: false, error: "not-found" });
|
||||
if (!isLoopback(req.socket.remoteAddress)) return send(403, { ok: false, error: "control is loopback-only" });
|
||||
if (req.method === "GET" && req.url === "/control/received") return send(200, master.received);
|
||||
const body = raw.length ? JSON.parse(raw.toString("utf8")) : {};
|
||||
if (req.method === "POST" && req.url === "/control/config") {
|
||||
master.emailUrl = body.email_url;
|
||||
return send(200, { ok: true });
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/control/command") {
|
||||
const { type, ...fields } = body;
|
||||
const [status, resp] = await master.command(type, fields);
|
||||
return send(200, { status, body: resp });
|
||||
}
|
||||
send(404, { ok: false, error: "not-found" });
|
||||
} catch (e) {
|
||||
send(500, { ok: false, error: String(e) });
|
||||
}
|
||||
});
|
||||
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const arg = (name, dflt) => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 ? process.argv[i + 1] : dflt;
|
||||
};
|
||||
const master = new OutsideMaster({ peerId: arg("peer-id", "cms-1"), key: Buffer.from(arg("key-hex"), "hex"),
|
||||
emailUrl: arg("email-url", null) });
|
||||
const server = await serve(master, Number(arg("port", "0")));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
process.stdout.write(JSON.stringify({ events_url: `${base}/events`, control_url: `${base}/control` }) + "\n");
|
||||
}
|
||||
84
impl/js/src/parser.mjs
Normal file
84
impl/js/src/parser.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// MAS canonical local-part parser, written from mas-v0.2-spec/grammar/mas-v0.2.abnf
|
||||
// and SPECIFICATION.md section 4. No I/O.
|
||||
|
||||
const ATOM_CHAR = "A-Za-z0-9#%&'*\\-/?_`{|}~";
|
||||
const ATOM = `[${ATOM_CHAR}]+`;
|
||||
const VALUE_SEG = `[${ATOM_CHAR}=]+`;
|
||||
|
||||
const DOT_STRING = new RegExp(`^${ATOM}(?:\\.${ATOM})*$`);
|
||||
const VALUE = new RegExp(`^${VALUE_SEG}(?:\\.${VALUE_SEG})*$`);
|
||||
const RESOURCE_TYPE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
||||
|
||||
export class MasSyntaxError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message ?? code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a canonical MAS local-part.
|
||||
* @param {string} local
|
||||
* @param {"p1"|"p2"} profile P1 rejects interactions; P2 accepts them.
|
||||
* @returns {{target: string, target_kind: "opaque"|"resource", resource_type?: string,
|
||||
* resource_id?: string, facet: string|null, interaction: string|null,
|
||||
* required_profile: "P1"|"P2", generator_allowed: boolean}}
|
||||
*/
|
||||
export function parseLocalPart(local, profile = "p2") {
|
||||
if (typeof local !== "string" || local.length === 0) throw new MasSyntaxError("empty");
|
||||
if (/[^\x21-\x7e]/.test(local)) throw new MasSyntaxError("non-ascii", "outside the ASCII grammar");
|
||||
if (/[$^]/.test(local)) throw new MasSyntaxError("reserved-operator");
|
||||
|
||||
let rest = local;
|
||||
let interaction = null;
|
||||
const bang = rest.indexOf("!");
|
||||
if (bang >= 0) {
|
||||
if (rest.indexOf("!", bang + 1) >= 0) throw new MasSyntaxError("multiple-interactions");
|
||||
interaction = rest.slice(bang + 1);
|
||||
rest = rest.slice(0, bang);
|
||||
if (interaction.includes("+")) throw new MasSyntaxError("operator-order", "'+' after '!'");
|
||||
if (!interaction) throw new MasSyntaxError("empty-interaction");
|
||||
if (!VALUE.test(interaction)) throw new MasSyntaxError("invalid-interaction");
|
||||
if (profile === "p1") throw new MasSyntaxError("interaction-not-in-p1");
|
||||
}
|
||||
|
||||
let facet = null;
|
||||
const plus = rest.indexOf("+");
|
||||
if (plus >= 0) {
|
||||
if (rest.indexOf("+", plus + 1) >= 0) throw new MasSyntaxError("multiple-facets");
|
||||
facet = rest.slice(plus + 1);
|
||||
rest = rest.slice(0, plus);
|
||||
if (!facet) throw new MasSyntaxError("empty-facet");
|
||||
if (!VALUE.test(facet)) throw new MasSyntaxError("invalid-facet");
|
||||
}
|
||||
|
||||
const target = rest;
|
||||
if (!target) throw new MasSyntaxError("empty-target");
|
||||
const out = { target, facet, interaction };
|
||||
const eq = target.indexOf("=");
|
||||
if (eq >= 0) {
|
||||
const type = target.slice(0, eq);
|
||||
const id = target.slice(eq + 1);
|
||||
if (!type) throw new MasSyntaxError("empty-resource-type");
|
||||
if (!id) throw new MasSyntaxError("empty-resource-id");
|
||||
if (!RESOURCE_TYPE.test(type)) throw new MasSyntaxError("invalid-resource-type");
|
||||
if (!VALUE.test(id)) throw new MasSyntaxError("invalid-resource-id");
|
||||
Object.assign(out, { target_kind: "resource", resource_type: type, resource_id: id });
|
||||
} else {
|
||||
if (!DOT_STRING.test(target)) throw new MasSyntaxError("invalid-target");
|
||||
out.target_kind = "opaque";
|
||||
}
|
||||
out.required_profile = interaction === null ? "P1" : "P2";
|
||||
out.generator_allowed = !(facet !== null && interaction !== null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render parsed components back to a local-part. */
|
||||
export function render(target, facet, interaction) {
|
||||
return target + (facet !== null && facet !== undefined ? "+" + facet : "")
|
||||
+ (interaction !== null && interaction !== undefined ? "!" + interaction : "");
|
||||
}
|
||||
|
||||
/** SMTP local-part limit (SPECIFICATION section 13), in octets. */
|
||||
export const MAX_OCTETS = 64;
|
||||
export const octets = (s) => new TextEncoder().encode(s).length;
|
||||
38
impl/js/src/signing.mjs
Normal file
38
impl/js/src/signing.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Peer authentication, written from mas-modes-v0.1-spec/CONTRACT.md section 7.
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const PEER = "mas-modes-peer";
|
||||
export const TIMESTAMP = "mas-modes-timestamp";
|
||||
export const SIGNATURE = "mas-modes-signature";
|
||||
export const WINDOW = 300;
|
||||
|
||||
/** "v1=" + hex HMAC-SHA256(key, TIMESTAMP + "." + BODY). body: Buffer or string. */
|
||||
export function signature(key, timestamp, body) {
|
||||
return "v1=" + createHmac("sha256", key).update(`${timestamp}.`).update(body).digest("hex");
|
||||
}
|
||||
|
||||
export function sign(body, peer, key, timestamp = Math.floor(Date.now() / 1000)) {
|
||||
return { "MAS-Modes-Peer": peer, "MAS-Modes-Timestamp": String(timestamp),
|
||||
"MAS-Modes-Signature": signature(key, timestamp, body) };
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(code, status = 401) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the authenticated peer id or throws AuthError. keys: {peer: Buffer}. */
|
||||
export function verify(headers, body, keys, now = Date.now() / 1000) {
|
||||
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
||||
const peer = h[PEER], ts = h[TIMESTAMP], sig = h[SIGNATURE];
|
||||
const key = peer ? keys[peer] : undefined;
|
||||
if (!key || !ts || !sig || !/^\d+$/.test(ts)) throw new AuthError("unauthenticated-peer");
|
||||
const want = Buffer.from(signature(key, Number(ts), body));
|
||||
const got = Buffer.from(String(sig));
|
||||
if (want.length !== got.length || !timingSafeEqual(want, got)) throw new AuthError("unauthenticated-peer");
|
||||
if (Math.abs(now - Number(ts)) > WINDOW) throw new AuthError("replayed");
|
||||
return peer;
|
||||
}
|
||||
131
impl/js/src/statute.mjs
Normal file
131
impl/js/src/statute.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// v0.2 statute-aware rules, written from mas-v0.2-spec/SPECIFICATION.md:
|
||||
// stand-ins (6.3), equivalence (8), destination transforms (12.1), length (13).
|
||||
// Pure functions over a statute document; no I/O, no schema validation.
|
||||
|
||||
import { parseLocalPart, render, MasSyntaxError, MAX_OCTETS, octets } from "./parser.mjs";
|
||||
|
||||
export class Statute {
|
||||
/** @param {object} doc a statute document (already validated by its publisher) */
|
||||
constructor(doc) {
|
||||
this.namespace = doc.namespace;
|
||||
this.version = doc.mas_version;
|
||||
const v2 = this.version === "0.2";
|
||||
this.p2 = Boolean(doc.profiles?.p2);
|
||||
this.resourceTypes = new Set(Object.keys(doc.resource_types ?? {}));
|
||||
this.modes = new Set(Object.entries(doc.interaction_modes ?? {})
|
||||
.filter(([, m]) => m.enabled !== false).map(([k]) => k));
|
||||
this.localPartCase = doc.equivalence?.local_part_case ?? "exact";
|
||||
this.resourceIdCase = v2 ? doc.equivalence?.resource_id_case ?? "exact" : "exact";
|
||||
this.altResource = v2 && Boolean(doc.alternate_syntax?.resource);
|
||||
this.altInteraction = v2 && Boolean(doc.alternate_syntax?.interaction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a received local-part under a statute (6.3 matching order).
|
||||
* @returns {{canonical: string, stand_ins: string[], parsed: object}}
|
||||
*/
|
||||
export function interpret(local, statute) {
|
||||
let rest = local;
|
||||
const standIns = [];
|
||||
// 1. Interaction: no "!" yet, and the local-part ends in ---MODE, MODE enabled.
|
||||
if (statute.altInteraction && statute.p2 && !rest.includes("!")) {
|
||||
const at = rest.lastIndexOf("---");
|
||||
if (at > 0) {
|
||||
const mode = rest.slice(at + 3);
|
||||
if (statute.modes.has(mode)) {
|
||||
rest = rest.slice(0, at) + "!" + mode;
|
||||
standIns.push("interaction");
|
||||
}
|
||||
}
|
||||
}
|
||||
let parsed = parseLocalPart(rest, "p2");
|
||||
// 2. Resource: the remaining target is TYPE--ID with TYPE declared and ID not
|
||||
// beginning with "-"; split at the first "--".
|
||||
if (statute.altResource && parsed.target_kind === "opaque") {
|
||||
const at = parsed.target.indexOf("--");
|
||||
const id = parsed.target.slice(at + 2);
|
||||
if (at > 0 && statute.resourceTypes.has(parsed.target.slice(0, at)) && id && !id.startsWith("-")) {
|
||||
const target = parsed.target.slice(0, at) + "=" + parsed.target.slice(at + 2);
|
||||
parsed = parseLocalPart(render(target, parsed.facet, parsed.interaction), "p2");
|
||||
standIns.push("resource");
|
||||
}
|
||||
}
|
||||
return { canonical: render(parsed.target, parsed.facet, parsed.interaction), stand_ins: standIns, parsed };
|
||||
}
|
||||
|
||||
/** What a local-part names (8): the target under the statute's case rules. */
|
||||
export function targetKey(parsed, statute) {
|
||||
const fold = statute.localPartCase === "ascii-insensitive";
|
||||
if (parsed.target_kind === "resource") {
|
||||
const id = fold || statute.resourceIdCase === "insensitive"
|
||||
? parsed.resource_id.toLowerCase() : parsed.resource_id;
|
||||
return `${parsed.resource_type}=${id}`;
|
||||
}
|
||||
return fold ? parsed.target.toLowerCase() : parsed.target;
|
||||
}
|
||||
|
||||
export function equivalent(a, b, statute) {
|
||||
return targetKey(interpret(a, statute).parsed, statute) === targetKey(interpret(b, statute).parsed, statute);
|
||||
}
|
||||
|
||||
export class CannotExpress extends Error {
|
||||
constructor(reason) {
|
||||
super(reason);
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSFORMS = ["drop-facet", "drop-interaction", "resource-alternate", "interaction-alternate", "lowercase"];
|
||||
const LOSSY = new Set(["drop-facet", "drop-interaction"]);
|
||||
|
||||
/**
|
||||
* Write a canonical local-part for a destination that needs `needs` (12.1).
|
||||
* Transforms apply only where they change something; lossy ones only on
|
||||
* explicit request. Refuses (CannotExpress) rather than emit a form the
|
||||
* namespace would read differently, or one over 64 octets (13).
|
||||
*/
|
||||
export function forDestination(local, statute, needs = [], { allowLossy = false } = {}) {
|
||||
for (const n of needs) if (!TRANSFORMS.includes(n)) throw new Error(`unknown transform ${n}`);
|
||||
if (needs.some((n) => LOSSY.has(n)) && !allowLossy) throw new CannotExpress("lossy transform not requested");
|
||||
const p = parseLocalPart(local, "p2");
|
||||
let facet = p.facet;
|
||||
let interaction = p.interaction;
|
||||
if (needs.includes("drop-facet")) facet = null;
|
||||
if (needs.includes("drop-interaction")) interaction = null;
|
||||
const want = { key: targetKey(p, statute), facet, interaction };
|
||||
|
||||
let target = p.target;
|
||||
if (needs.includes("resource-alternate") && p.target_kind === "resource") {
|
||||
if (!statute.altResource) throw new CannotExpress("statute does not enable the resource stand-in");
|
||||
target = `${p.resource_type}--${p.resource_id}`;
|
||||
}
|
||||
let text = target + (facet !== null ? "+" + facet : "");
|
||||
if (interaction !== null) {
|
||||
if (needs.includes("interaction-alternate")) {
|
||||
if (!statute.altInteraction || !statute.modes.has(interaction)) {
|
||||
throw new CannotExpress("statute does not enable the interaction stand-in for this mode");
|
||||
}
|
||||
text += "---" + interaction;
|
||||
} else {
|
||||
text += "!" + interaction;
|
||||
}
|
||||
}
|
||||
if (needs.includes("lowercase") && text !== text.toLowerCase()) {
|
||||
if (statute.resourceIdCase !== "insensitive") throw new CannotExpress("statute does not make identifiers case-insensitive");
|
||||
text = text.toLowerCase();
|
||||
}
|
||||
if (octets(text) > MAX_OCTETS) throw new CannotExpress(`${octets(text)} octets after transforms`);
|
||||
|
||||
let got;
|
||||
try {
|
||||
got = interpret(text, statute).parsed;
|
||||
} catch (e) {
|
||||
if (e instanceof MasSyntaxError) throw new CannotExpress(`not parseable: ${e.code}`);
|
||||
throw e;
|
||||
}
|
||||
if (targetKey(got, statute) !== want.key || got.facet !== want.facet || got.interaction !== want.interaction) {
|
||||
throw new CannotExpress(`${text} would be read as ${render(got.target, got.facet, got.interaction)}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
31
impl/js/test/address.test.mjs
Normal file
31
impl/js/test/address.test.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// mas-v0.2-spec/conformance/addresses.json, consumed unchanged (CONFORMANCE.md section 2).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseLocalPart, MasSyntaxError } from "../src/parser.mjs";
|
||||
|
||||
const BUNDLE = new URL("../../../mas-v0.2-spec/", import.meta.url);
|
||||
const fixtures = JSON.parse(readFileSync(new URL("conformance/addresses.json", BUNDLE)));
|
||||
|
||||
for (const f of fixtures) {
|
||||
test(`${f.id} ${JSON.stringify(f.local_part)} (${f.profile})`, () => {
|
||||
let got;
|
||||
try {
|
||||
got = parseLocalPart(f.local_part, f.profile);
|
||||
} catch (e) {
|
||||
assert.ok(e instanceof MasSyntaxError, e);
|
||||
assert.equal(f.valid, false, `rejected (${e.code}) but fixture is valid`);
|
||||
return;
|
||||
}
|
||||
assert.equal(f.valid, true, `accepted but fixture is invalid (${f.error})`);
|
||||
const { required_profile, generator_allowed, ...parsed } = got;
|
||||
assert.deepEqual(parsed, f.parsed);
|
||||
if ("generator_allowed" in f) assert.equal(generator_allowed, f.generator_allowed);
|
||||
});
|
||||
}
|
||||
|
||||
test("a P2 parser also passes every P1 fixture (CONFORMANCE.md 2)", () => {
|
||||
for (const f of fixtures.filter((x) => x.profile === "p1" && x.valid)) {
|
||||
assert.deepEqual((({ required_profile, generator_allowed, ...p }) => p)(parseLocalPart(f.local_part, "p2")), f.parsed);
|
||||
}
|
||||
});
|
||||
27
impl/js/test/signing.test.mjs
Normal file
27
impl/js/test/signing.test.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// mas-modes-v0.1-spec/conformance/auth.json, consumed unchanged (CONTRACT.md section 7).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { signature, verify, AuthError } from "../src/signing.mjs";
|
||||
|
||||
const fx = JSON.parse(readFileSync(new URL("../../../mas-modes-v0.1-spec/conformance/auth.json", import.meta.url)));
|
||||
|
||||
for (const v of fx.vectors) {
|
||||
test(`${v.id} signature`, () => {
|
||||
assert.equal(signature(Buffer.from(v.key_hex, "hex"), v.timestamp, Buffer.from(v.body, "utf8")), v.signature);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of fx.verify) {
|
||||
test(`${c.id} ${c.why}`, () => {
|
||||
const keys = Object.fromEntries(Object.entries(c.keys).map(([p, k]) => [p, Buffer.from(k, "hex")]));
|
||||
let got;
|
||||
try {
|
||||
got = verify(c.headers, Buffer.from(c.body, "utf8"), keys, c.now);
|
||||
} catch (e) {
|
||||
assert.ok(e instanceof AuthError);
|
||||
got = "error:" + e.code;
|
||||
}
|
||||
assert.equal(got, "peer" in c ? c.peer : "error:" + c.error);
|
||||
});
|
||||
}
|
||||
30
impl/js/test/statute-aware.test.mjs
Normal file
30
impl/js/test/statute-aware.test.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// mas-v0.2-spec/conformance/statute-aware.json, consumed unchanged (CONFORMANCE.md section 5).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Statute, interpret, equivalent, forDestination, CannotExpress } from "../src/statute.mjs";
|
||||
|
||||
const BUNDLE = new URL("../../../mas-v0.2-spec/", import.meta.url);
|
||||
const fx = JSON.parse(readFileSync(new URL("conformance/statute-aware.json", BUNDLE)));
|
||||
const statutes = Object.fromEntries(Object.entries(fx.statutes).map(([k, d]) => [k, new Statute(d)]));
|
||||
|
||||
for (const c of fx.interpretation) {
|
||||
test(`${c.id} interpret ${c.local_part}`, () => {
|
||||
const r = interpret(c.local_part, statutes[c.statute]);
|
||||
assert.deepEqual([r.canonical, r.stand_ins], [c.canonical, c.stand_ins]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of fx.equivalence) {
|
||||
test(`${c.id} ${c.a} ~ ${c.b}`, () => {
|
||||
assert.equal(equivalent(c.a, c.b, statutes[c.statute]), c.equivalent);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of fx.destination) {
|
||||
test(`${c.id} ${c.local_part} for [${c.needs}]`, () => {
|
||||
const run = () => forDestination(c.local_part, statutes[c.statute], c.needs, { allowLossy: c.allow_lossy ?? false });
|
||||
if (c.expect === null) assert.throws(run, CannotExpress, c.reason);
|
||||
else assert.equal(run(), c.expect);
|
||||
});
|
||||
}
|
||||
|
|
@ -28,6 +28,12 @@ New features, opt-in per statute with `"mas_version": "0.2"`
|
|||
the separate MAS mode contract defines (`mas-modes-v0.1-spec/`,
|
||||
MAS-WP-0010). It is informative for parsers and generators, with no
|
||||
grammar or statute change.
|
||||
- **Stand-in matching edge cases (SCP-9), §6.3.** Found by the second,
|
||||
independent implementation (MAS-WP-0009, differential testing). An
|
||||
interaction stand-in is not read when the local-part already has `!`. A
|
||||
resource stand-in needs an `ID` that does not begin with `-`, so
|
||||
`TYPE---…` is never a resource stand-in. Fixtures si-011 to si-013 and
|
||||
dt-109 were added. The ABNF comments no longer say "v0.1".
|
||||
- **Conformance (MAS-WP-0008).** `conformance/statute-aware.json` and
|
||||
the `MAS-V0.2-STATUTE-AWARE` surface cover the stand-in table, the
|
||||
`resource_id_case` equivalence and destination-transform refusals.
|
||||
|
|
|
|||
|
|
@ -253,14 +253,16 @@ and only a statute-aware implementation interprets it.
|
|||
**Matching order.** A statute-aware implementation interprets a local-part
|
||||
in this order:
|
||||
|
||||
1. **Interaction.** If the interaction stand-in is enabled and the
|
||||
local-part ends in `---MODE`, where `MODE` is an interaction mode the
|
||||
statute enables, the suffix after the last such `---` is the
|
||||
interaction, equivalent to `!MODE`.
|
||||
1. **Interaction.** If the interaction stand-in is enabled, the local-part
|
||||
has no `!`, and it ends in `---MODE`, where `MODE` is an interaction
|
||||
mode the statute enables, then the suffix after the last such `---` is
|
||||
the interaction, equivalent to `!MODE`. A stand-in stands for an absent
|
||||
operator: a local-part that already has `!` is read canonically.
|
||||
2. **Resource.** If the resource stand-in is enabled and the remaining
|
||||
target has the form `TYPE--ID`, where `TYPE` is a resource type the
|
||||
statute declares, it is equivalent to `TYPE=ID`. The split is at the
|
||||
first `--`.
|
||||
statute declares and `ID` does not begin with `-`, it is equivalent to
|
||||
`TYPE=ID`. The split is at the first `--`. `TYPE---…` is never a
|
||||
resource stand-in: three dashes belong to the interaction stand-in.
|
||||
|
||||
| Received | Equivalent canonical form |
|
||||
|---|---|
|
||||
|
|
@ -268,6 +270,8 @@ in this order:
|
|||
| `ticket--gs1---callback` | `ticket=gs1!callback` |
|
||||
| `ticket--a---b` (`b` not an enabled mode) | `ticket=a---b` |
|
||||
| `ticket--gs1+comments` | `ticket=gs1+comments` |
|
||||
| `ticket---chat` (`chat` not enabled) | none: opaque `ticket---chat` |
|
||||
| `q!---callback` | none: canonical `q!---callback` |
|
||||
|
||||
**Constraints.**
|
||||
|
||||
|
|
@ -275,6 +279,8 @@ in this order:
|
|||
- Interaction mode names MUST NOT start with `-`.
|
||||
- A namespace that enables the interaction stand-in MUST NOT issue resource
|
||||
identifiers containing `---`.
|
||||
- A resource identifier that begins with `-` cannot be written with the
|
||||
resource stand-in. A generator refuses that transform (§12.1).
|
||||
- A namespace with existing opaque names matching a stand-in pattern MUST
|
||||
NOT enable that stand-in.
|
||||
|
||||
|
|
|
|||
|
|
@ -239,6 +239,30 @@
|
|||
"canonical": "ticket--gs1",
|
||||
"stand_ins": [],
|
||||
"note": "v0.1 statute has no stand-ins"
|
||||
},
|
||||
{
|
||||
"id": "si-011",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "ticket---",
|
||||
"canonical": "ticket---",
|
||||
"stand_ins": [],
|
||||
"note": "TYPE---... is never a resource stand-in (SCP-9)"
|
||||
},
|
||||
{
|
||||
"id": "si-012",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "ticket---chat",
|
||||
"canonical": "ticket---chat",
|
||||
"stand_ins": [],
|
||||
"note": "chat not enabled; the id would begin with '-' (SCP-9)"
|
||||
},
|
||||
{
|
||||
"id": "si-013",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "q!---callback",
|
||||
"canonical": "q!---callback",
|
||||
"stand_ins": [],
|
||||
"note": "already has '!': read canonically (SCP-9)"
|
||||
}
|
||||
],
|
||||
"equivalence": [
|
||||
|
|
@ -421,6 +445,16 @@
|
|||
],
|
||||
"expect": null,
|
||||
"reason": "mode not enabled by the statute"
|
||||
},
|
||||
{
|
||||
"id": "dt-109",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "ticket=-x",
|
||||
"needs": [
|
||||
"resource-alternate"
|
||||
],
|
||||
"expect": null,
|
||||
"reason": "an id beginning with '-' cannot use the resource stand-in (SCP-9)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
; P1 accepts: target [facet]
|
||||
; P2 accepts: target [facet] [interaction]
|
||||
;
|
||||
; Generators MUST NOT emit both facet and interaction in v0.1 even though
|
||||
; P2 parsers accept the combined canonical form.
|
||||
; Generators MUST NOT emit both facet and interaction even though P2
|
||||
; parsers accept the combined canonical form.
|
||||
|
||||
mas-local-p1 = target [facet]
|
||||
mas-local-p2 = target [facet] [interaction]
|
||||
|
|
@ -58,5 +58,6 @@ value-char = atom-char / "="
|
|||
; Explicitly excluded from canonical values:
|
||||
; "+" "!" "$" "^" "@"
|
||||
;
|
||||
; Quoted local-parts are outside canonical MAS v0.1.
|
||||
; SMTPUTF8 local-parts are outside canonical MAS v0.1 machine grammar.
|
||||
; Quoted local-parts are outside canonical MAS (a quoted form whose content
|
||||
; is canonical is equivalent to it, SPECIFICATION section 4).
|
||||
; SMTPUTF8 local-parts are outside the canonical MAS machine grammar.
|
||||
|
|
|
|||
|
|
@ -239,6 +239,30 @@
|
|||
"canonical": "ticket--gs1",
|
||||
"stand_ins": [],
|
||||
"note": "v0.1 statute has no stand-ins"
|
||||
},
|
||||
{
|
||||
"id": "si-011",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "ticket---",
|
||||
"canonical": "ticket---",
|
||||
"stand_ins": [],
|
||||
"note": "TYPE---... is never a resource stand-in (SCP-9)"
|
||||
},
|
||||
{
|
||||
"id": "si-012",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "ticket---chat",
|
||||
"canonical": "ticket---chat",
|
||||
"stand_ins": [],
|
||||
"note": "chat not enabled; the id would begin with '-' (SCP-9)"
|
||||
},
|
||||
{
|
||||
"id": "si-013",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "q!---callback",
|
||||
"canonical": "q!---callback",
|
||||
"stand_ins": [],
|
||||
"note": "already has '!': read canonically (SCP-9)"
|
||||
}
|
||||
],
|
||||
"equivalence": [
|
||||
|
|
@ -421,6 +445,16 @@
|
|||
],
|
||||
"expect": null,
|
||||
"reason": "mode not enabled by the statute"
|
||||
},
|
||||
{
|
||||
"id": "dt-109",
|
||||
"statute": "stand-ins",
|
||||
"local_part": "ticket=-x",
|
||||
"needs": [
|
||||
"resource-alternate"
|
||||
],
|
||||
"expect": null,
|
||||
"reason": "an id beginning with '-' cannot use the resource stand-in (SCP-9)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
78
mas_modes/external.py
Normal file
78
mas_modes/external.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""An outside master in another process, driven through its control API.
|
||||
|
||||
Same interface as `stubs.master.StubMaster`, so the harness and the flow
|
||||
runner can swap it in. Used for the independent JS master in
|
||||
`impl/js/src/master.mjs` (MAS-WP-0009).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
JS_MASTER = ROOT / "impl/js/src/master.mjs"
|
||||
|
||||
|
||||
def _call(url: str, body: Optional[dict] = None):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method="GET" if body is None else "POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
class ExternalMaster:
|
||||
def __init__(self, argv: list[str]):
|
||||
self.proc = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
|
||||
info = json.loads(self.proc.stdout.readline())
|
||||
self.events_url, self.control_url = info["events_url"], info["control_url"]
|
||||
self._email_url = None
|
||||
|
||||
@classmethod
|
||||
def js(cls, key: bytes, peer_id: str = "cms-1") -> "ExternalMaster":
|
||||
return cls(["node", str(JS_MASTER), "--peer-id", peer_id, "--key-hex", key.hex()])
|
||||
|
||||
@property
|
||||
def email_url(self):
|
||||
return self._email_url
|
||||
|
||||
@email_url.setter
|
||||
def email_url(self, url):
|
||||
self._email_url = url
|
||||
_call(self.control_url + "/config", {"email_url": url})
|
||||
|
||||
@property
|
||||
def received(self) -> list[dict]:
|
||||
return _call(self.control_url + "/received")
|
||||
|
||||
def of_type(self, type_: str) -> list[dict]:
|
||||
return [m for m in self.received if m["type"] == type_]
|
||||
|
||||
def command(self, type_: str, case: Optional[dict] = None, **fields):
|
||||
out = _call(self.control_url + "/command",
|
||||
{"type": type_, **({"case": case} if case else {}),
|
||||
**{k: v for k, v in fields.items() if v is not None}})
|
||||
return out["status"], out["body"]
|
||||
|
||||
def assign(self, intake_ref: str, resource_id: str):
|
||||
return self.command("case.assign", intake_ref=intake_ref, resource_id=resource_id)
|
||||
|
||||
def policy(self, case: dict, autonomous_send=None, transfers=None):
|
||||
return self.command("case.policy", case, autonomous_send=autonomous_send, transfers=transfers)
|
||||
|
||||
def send(self, case: dict, in_reply_to: str, body: str, subject: Optional[str] = None):
|
||||
return self.command("mail.send", case, in_reply_to=in_reply_to, body=body, subject=subject)
|
||||
|
||||
def cancel(self, case: dict, handoff_id: str, reason: str = "timed-out"):
|
||||
return self.command("handoff.cancel", case, handoff_id=handoff_id, reason=reason)
|
||||
|
||||
def pump(self):
|
||||
return []
|
||||
|
||||
def close(self):
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=10)
|
||||
|
|
@ -52,7 +52,9 @@ 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):
|
||||
sign_events: bool = False, keys: Optional[dict] = None, master_factory=None):
|
||||
"""master_factory(key) -> an outside master with StubMaster's interface and
|
||||
an `events_url` (e.g. `ExternalMaster.js`); default: the Python stub."""
|
||||
self.resolver = Resolver.with_statute_files([statute_file])
|
||||
[self.domain] = self.resolver.pinned
|
||||
self.clock = FakeClock()
|
||||
|
|
@ -60,8 +62,12 @@ class Harness:
|
|||
self.callback = CallbackStub({"email": self.keys["callback-1"]}, default=scenario)
|
||||
self._servers = []
|
||||
cb_url = self.callback_url = self._serve(self.callback.server()) + "/handoffs"
|
||||
self.master = StubMaster({"email": self.keys["cms-1"]}, auto_assign=auto_assign)
|
||||
master_url = self._serve(self.master.server()) + "/events"
|
||||
if master_factory is None:
|
||||
self.master = StubMaster({"email": self.keys["cms-1"]}, auto_assign=auto_assign)
|
||||
master_url = self._serve(self.master.server()) + "/events"
|
||||
else:
|
||||
self.master = master_factory(self.keys["cms-1"])
|
||||
master_url = self.master.events_url
|
||||
|
||||
def fill(v):
|
||||
if isinstance(v, str):
|
||||
|
|
@ -98,6 +104,8 @@ class Harness:
|
|||
def close(self):
|
||||
for s in self._servers:
|
||||
s.shutdown()
|
||||
if hasattr(self.master, "close"):
|
||||
self.master.close()
|
||||
|
||||
# -- driving -------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -206,8 +206,9 @@ def _master(h, cmd, ctx):
|
|||
raise KeyError(f"unknown master command {c!r}")
|
||||
|
||||
|
||||
def run_flow(flow: dict, doc: Optional[dict] = None) -> list[str]:
|
||||
"""Run one flow from the bundle (FLOWS.md). Returns the problems found."""
|
||||
def run_flow(flow: dict, doc: Optional[dict] = None, master_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")
|
||||
bad = undefined_keys(flow)
|
||||
if bad:
|
||||
|
|
@ -215,41 +216,51 @@ def run_flow(flow: dict, doc: Optional[dict] = None) -> list[str]:
|
|||
keys = {p: bytes.fromhex(k) for p, k in doc["peers"].items()}
|
||||
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)
|
||||
auto_assign=False, max_sent=flow.get("max_sent"), keys=keys,
|
||||
master_factory=master_factory)
|
||||
ctx: dict = {}
|
||||
problems = []
|
||||
try:
|
||||
for n, step in enumerate(flow["steps"], 1):
|
||||
seen = (len(h.callback.received), len(h.master.received))
|
||||
event, response = None, None
|
||||
if "mail" in step:
|
||||
m = _fill(step["mail"], ctx)
|
||||
event = h.mail(m["to"], m.get("text", ""), subject=m.get("subject", "Help needed"),
|
||||
dmarc=m.get("dmarc", "pass"))
|
||||
elif "mode" in step:
|
||||
h.callback.run()
|
||||
elif "advance" in step:
|
||||
h.advance(step["advance"])
|
||||
elif "master" in step:
|
||||
response = _master(h, _fill(step["master"], ctx), ctx)
|
||||
elif "inject" in step:
|
||||
response = _inject(h, step["inject"], ctx)
|
||||
_capture(ctx, h.callback.received[seen[0]:] + h.master.received[seen[1]:])
|
||||
if response is not None:
|
||||
_capture(ctx, [response[1]])
|
||||
for key, want in step.get("expect", {}).items():
|
||||
got = _observe(h, key, event, response)
|
||||
if key == "master_received":
|
||||
got = {t: got.get(t, 0) for t in want}
|
||||
elif key == "last_mail_contains":
|
||||
got, want = (want in (got or "")), True
|
||||
if got != want:
|
||||
problems.append(f"step {n} {key}: got {got!r}, expected {want!r}")
|
||||
try:
|
||||
_step(h, n, step, ctx, problems)
|
||||
except Exception as e: # a broken peer must fail the flow, not the run
|
||||
problems.append(f"step {n} raised {type(e).__name__}: {e}")
|
||||
break
|
||||
finally:
|
||||
h.close()
|
||||
return problems
|
||||
|
||||
|
||||
def _step(h, n, step, ctx, problems):
|
||||
"""One flow step (FLOWS.md), then its expectations."""
|
||||
seen = (len(h.callback.received), len(h.master.received))
|
||||
event, response = None, None
|
||||
if "mail" in step:
|
||||
m = _fill(step["mail"], ctx)
|
||||
event = h.mail(m["to"], m.get("text", ""), subject=m.get("subject", "Help needed"),
|
||||
dmarc=m.get("dmarc", "pass"))
|
||||
elif "mode" in step:
|
||||
h.callback.run()
|
||||
elif "advance" in step:
|
||||
h.advance(step["advance"])
|
||||
elif "master" in step:
|
||||
response = _master(h, _fill(step["master"], ctx), ctx)
|
||||
elif "inject" in step:
|
||||
response = _inject(h, step["inject"], ctx)
|
||||
_capture(ctx, h.callback.received[seen[0]:] + h.master.received[seen[1]:])
|
||||
if response is not None:
|
||||
_capture(ctx, [response[1]])
|
||||
for key, want in step.get("expect", {}).items():
|
||||
got = _observe(h, key, event, response)
|
||||
if key == "master_received":
|
||||
got = {t: got.get(t, 0) for t in want}
|
||||
elif key == "last_mail_contains":
|
||||
got, want = (want in (got or "")), True
|
||||
if got != want:
|
||||
problems.append(f"step {n} {key}: got {got!r}, expected {want!r}")
|
||||
|
||||
|
||||
def _observe(h, key, event, response):
|
||||
body = (response[1] if response else None) or {}
|
||||
if key == "event_accepted":
|
||||
|
|
@ -284,13 +295,19 @@ def _observe(h, key, event, response):
|
|||
raise KeyError(f"unknown expectation {key!r}")
|
||||
|
||||
|
||||
def run_mode_corpus() -> Report:
|
||||
def run_mode_corpus(master_factory=None, roles=("master", "slave")) -> Report:
|
||||
"""All fixtures and flows. With master_factory, only the flows of the given
|
||||
setup roles run, against that outside master."""
|
||||
report = Report()
|
||||
check_messages(report)
|
||||
check_auth(report)
|
||||
if master_factory is None:
|
||||
check_messages(report)
|
||||
check_auth(report)
|
||||
doc = _load("conformance/flows.json")
|
||||
for flow in doc["flows"]:
|
||||
problems = run_flow(flow, doc)
|
||||
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)
|
||||
if problems:
|
||||
report.failures.append((flow["id"], "; ".join(problems)))
|
||||
else:
|
||||
|
|
|
|||
103
tests/test_differential.py
Normal file
103
tests/test_differential.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Differential test: mas_core against the independent JS implementation (MAS-WP-0009).
|
||||
|
||||
Generated local-parts go through both. Every disagreement is a spec finding
|
||||
or a bug. Known, recorded divergences are listed in KNOWN with their reason.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mas_conformance import statute_aware_fixtures
|
||||
from mas_core.generator import CannotExpress, for_destination
|
||||
from mas_core.parser import MasSyntaxError, parse_address, parse_for_profile
|
||||
from mas_core.policy import resolve
|
||||
from mas_core.statute import build_statute
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BATCH = ROOT / "impl/js/src/batch.mjs"
|
||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not available")
|
||||
|
||||
TOKENS = ["ticket", "Ticket", "invoice", "support", "case", "gs-1", "GS-1", "x", "a.b", "--", "---",
|
||||
"=", "==", "+", "!", "comments", "Comments", "callback", "chat", ".", "%2B", "_", "-", "$",
|
||||
'"', "ï", "RE=2026", "PR42", "q"]
|
||||
TRANSFORMS = ["resource-alternate", "interaction-alternate", "lowercase", "drop-facet", "drop-interaction"]
|
||||
|
||||
|
||||
def corpus(n, seed=20261019):
|
||||
rnd = random.Random(seed)
|
||||
out = set()
|
||||
while len(out) < n:
|
||||
out.add("".join(rnd.choice(TOKENS) for _ in range(rnd.randint(1, 6))))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def js(statutes, cases):
|
||||
proc = subprocess.run(["node", str(BATCH)], input=json.dumps({"statutes": statutes, "cases": cases}),
|
||||
capture_output=True, text=True, check=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def py_parse(local, profile):
|
||||
try:
|
||||
return {"ok": True, "parsed": parse_for_profile(local, profile).to_fixture_dict()}
|
||||
except MasSyntaxError:
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def py_interpret(local, st):
|
||||
try:
|
||||
sem = resolve(parse_address(f"{local}@{st.namespace}"), st)
|
||||
except MasSyntaxError:
|
||||
return {"ok": False}
|
||||
d = sem.to_dict()
|
||||
return {"ok": True, "canonical": d["canonical_local_part"], "stand_ins": d["stand_ins"],
|
||||
"key": sem.equivalence_key}
|
||||
|
||||
|
||||
def py_dest(local, st, needs, lossy):
|
||||
try:
|
||||
return {"ok": True, "text": for_destination(local, st, needs, allow_lossy=lossy).local_part}
|
||||
except (CannotExpress, MasSyntaxError):
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def strip(r):
|
||||
return {k: v for k, v in r.items() if k != "error"}
|
||||
|
||||
|
||||
DOCS = statute_aware_fixtures()["statutes"]
|
||||
STATUTES = {k: build_statute(d, d["namespace"]) for k, d in DOCS.items()}
|
||||
LOCALS = corpus(1500)
|
||||
|
||||
|
||||
def disagreements(cases, py):
|
||||
got = js(DOCS, cases)
|
||||
return [(c, strip(g), p) for c, g, p in zip(cases, got, (py(c) for c in cases)) if strip(g) != p]
|
||||
|
||||
|
||||
def test_parser_agrees():
|
||||
cases = [{"op": "parse", "local": lp, "profile": prof} for lp in LOCALS for prof in ("p1", "p2")]
|
||||
bad = disagreements(cases, lambda c: py_parse(c["local"], c["profile"]))
|
||||
assert bad == [], bad[:10]
|
||||
|
||||
|
||||
def test_interpretation_and_equivalence_agree():
|
||||
cases = [{"op": "interpret", "local": lp, "statute": s} for lp in LOCALS for s in DOCS]
|
||||
bad = disagreements(cases, lambda c: py_interpret(c["local"], STATUTES[c["statute"]]))
|
||||
assert bad == [], bad[:10]
|
||||
|
||||
|
||||
def test_destination_transforms_agree():
|
||||
rnd = random.Random(7)
|
||||
valid = [lp for lp in LOCALS if py_parse(lp, "p2")["ok"]]
|
||||
cases = []
|
||||
for lp in valid:
|
||||
for s in DOCS:
|
||||
needs = rnd.sample(TRANSFORMS, rnd.randint(0, 3))
|
||||
cases.append({"op": "dest", "local": lp, "statute": s, "needs": needs, "lossy": rnd.random() < 0.5})
|
||||
bad = disagreements(cases, lambda c: py_dest(c["local"], STATUTES[c["statute"]], c["needs"], c["lossy"]))
|
||||
assert bad == [], bad[:10]
|
||||
24
tests/test_js_impl.py
Normal file
24
tests/test_js_impl.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""The independent JS implementation (impl/js, MAS-WP-0009), where node is available."""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not available")
|
||||
|
||||
|
||||
def test_js_passes_the_bundles_unchanged():
|
||||
# addresses.json, statute-aware.json and auth.json, read from the bundles in place
|
||||
proc = subprocess.run(["node", "--test"], cwd=ROOT / "impl/js", capture_output=True, text=True)
|
||||
assert proc.returncode == 0, proc.stdout[-2000:] + proc.stderr[-2000:]
|
||||
assert "# fail 0" in proc.stdout or "ℹ fail 0" in proc.stdout
|
||||
|
||||
|
||||
def test_js_outside_master_passes_the_slave_flows():
|
||||
from mas_modes.external import ExternalMaster
|
||||
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
|
||||
|
|
@ -150,4 +150,4 @@ def test_v02_statute_aware_corpus():
|
|||
from mas_conformance.runner import run_statute_aware_corpus
|
||||
rep = run_statute_aware_corpus()
|
||||
assert rep.ok, rep.failures
|
||||
assert rep.passed == 29
|
||||
assert rep.passed == 33
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Declare MAS v0.2 final"
|
||||
domain: communication
|
||||
repo: mail-address-semantics
|
||||
status: backlog
|
||||
status: active
|
||||
owner: claude
|
||||
topic_slug: coulomb-social
|
||||
created: "2026-10-19"
|
||||
|
|
@ -23,7 +23,7 @@ until a second implementation or deployment exists.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0009-T01
|
||||
status: wait
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "65fe7a3f-4142-5834-bca8-3de6b8839986"
|
||||
```
|
||||
|
|
@ -43,7 +43,7 @@ read in a way `mas_core` does not.
|
|||
|
||||
```task
|
||||
id: MAS-WP-0009-T02
|
||||
status: wait
|
||||
status: progress
|
||||
priority: low
|
||||
state_hub_task_id: "a30c1b69-4b41-5636-865a-82341175668b"
|
||||
```
|
||||
|
|
@ -51,3 +51,32 @@ state_hub_task_id: "a30c1b69-4b41-5636-865a-82341175668b"
|
|||
Fold in the findings from T01, then remove "draft" from the bundle
|
||||
(README, SPECIFICATION, CONFORMANCE, CHANGELOG) and date the v0.2 release
|
||||
in the changelog.
|
||||
|
||||
## Outcome of T01 (2026-10-19)
|
||||
|
||||
- **Founder interview.** TypeScript on Node, with no dependencies, in
|
||||
`impl/js/`. Scope: parser, statute-aware rules and an outside master.
|
||||
Findings are reported before any decision to declare v0.2 final.
|
||||
- **`impl/js/`.** Written from the spec texts and fixtures only. It passes,
|
||||
unchanged and read in place: `addresses.json` (35), `statute-aware.json`
|
||||
(33 after SCP-9) and `auth.json` (14). Its outside master passes all 11
|
||||
slave-setup flows of `mas-modes-v0.1-spec` against the Python email side:
|
||||
11 of 11 with the right key, 0 of 11 with a wrong key.
|
||||
- **Differential test** (`tests/test_differential.py`): 1500 generated
|
||||
local-parts × parse (P1 and P2), interpretation under 4 statutes, and
|
||||
destination transforms.
|
||||
- **Findings.**
|
||||
1. §6.3 did not say that the interaction stand-in is read only without
|
||||
`!` (`q!---callback`).
|
||||
2. §6.3 did not say that the resource stand-in excludes an id beginning
|
||||
with `-` (`ticket---`, `ticket---chat`).
|
||||
|
||||
Both were `mas_core` behavior not written in the spec. They are now
|
||||
SCP-9 in the v0.2 draft, with fixtures si-011 to si-013 and dt-109.
|
||||
The ABNF comments still said "v0.1"; fixed. Also noted, not changed: the
|
||||
spec defines no concrete equivalence-key format. Conformance compares
|
||||
only equality, which both implementations agree on. The spec does not
|
||||
say whether a *parser* must reject more than 64 octets: `mas_core` does
|
||||
and the JS parser does not. No fixture covers it.
|
||||
- **Not independently validated:** the email side of the mode contract.
|
||||
It exists only in Python; the JS work covers the master side.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue