diff --git a/docs/spec-change-proposals.md b/docs/spec-change-proposals.md index 131a3b8..b260df8 100644 --- a/docs/spec-change-proposals.md +++ b/docs/spec-change-proposals.md @@ -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: diff --git a/impl/js/README.md b/impl/js/README.md new file mode 100644 index 0000000..c2d5f52 --- /dev/null +++ b/impl/js/README.md @@ -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`). diff --git a/impl/js/package.json b/impl/js/package.json new file mode 100644 index 0000000..3b930bf --- /dev/null +++ b/impl/js/package.json @@ -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"} +} diff --git a/impl/js/src/batch.mjs b/impl/js/src/batch.mjs new file mode 100644 index 0000000..c2190f2 --- /dev/null +++ b/impl/js/src/batch.mjs @@ -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)); diff --git a/impl/js/src/master.mjs b/impl/js/src/master.mjs new file mode 100644 index 0000000..486086d --- /dev/null +++ b/impl/js/src/master.mjs @@ -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"); +} diff --git a/impl/js/src/parser.mjs b/impl/js/src/parser.mjs new file mode 100644 index 0000000..8f7191a --- /dev/null +++ b/impl/js/src/parser.mjs @@ -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; diff --git a/impl/js/src/signing.mjs b/impl/js/src/signing.mjs new file mode 100644 index 0000000..c66c777 --- /dev/null +++ b/impl/js/src/signing.mjs @@ -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; +} diff --git a/impl/js/src/statute.mjs b/impl/js/src/statute.mjs new file mode 100644 index 0000000..19f0296 --- /dev/null +++ b/impl/js/src/statute.mjs @@ -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; +} diff --git a/impl/js/test/address.test.mjs b/impl/js/test/address.test.mjs new file mode 100644 index 0000000..a576adf --- /dev/null +++ b/impl/js/test/address.test.mjs @@ -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); + } +}); diff --git a/impl/js/test/signing.test.mjs b/impl/js/test/signing.test.mjs new file mode 100644 index 0000000..a23a904 --- /dev/null +++ b/impl/js/test/signing.test.mjs @@ -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); + }); +} diff --git a/impl/js/test/statute-aware.test.mjs b/impl/js/test/statute-aware.test.mjs new file mode 100644 index 0000000..a69b678 --- /dev/null +++ b/impl/js/test/statute-aware.test.mjs @@ -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); + }); +} diff --git a/mas-v0.2-spec/CHANGELOG.md b/mas-v0.2-spec/CHANGELOG.md index f737912..9fc4d7a 100644 --- a/mas-v0.2-spec/CHANGELOG.md +++ b/mas-v0.2-spec/CHANGELOG.md @@ -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. diff --git a/mas-v0.2-spec/SPECIFICATION.md b/mas-v0.2-spec/SPECIFICATION.md index dd25241..8801899 100644 --- a/mas-v0.2-spec/SPECIFICATION.md +++ b/mas-v0.2-spec/SPECIFICATION.md @@ -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. diff --git a/mas-v0.2-spec/conformance/statute-aware.json b/mas-v0.2-spec/conformance/statute-aware.json index 789ac4e..246a062 100644 --- a/mas-v0.2-spec/conformance/statute-aware.json +++ b/mas-v0.2-spec/conformance/statute-aware.json @@ -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)" } ] } diff --git a/mas-v0.2-spec/grammar/mas-v0.2.abnf b/mas-v0.2-spec/grammar/mas-v0.2.abnf index 1418d83..ed52e5c 100644 --- a/mas-v0.2-spec/grammar/mas-v0.2.abnf +++ b/mas-v0.2-spec/grammar/mas-v0.2.abnf @@ -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. diff --git a/mas_conformance/statute_aware.json b/mas_conformance/statute_aware.json index 789ac4e..246a062 100644 --- a/mas_conformance/statute_aware.json +++ b/mas_conformance/statute_aware.json @@ -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)" } ] } diff --git a/mas_modes/external.py b/mas_modes/external.py new file mode 100644 index 0000000..46669d7 --- /dev/null +++ b/mas_modes/external.py @@ -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) diff --git a/mas_modes/harness.py b/mas_modes/harness.py index 444b8b7..15eeda6 100644 --- a/mas_modes/harness.py +++ b/mas_modes/harness.py @@ -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 ------------------------------------------------------------------- diff --git a/mas_modes/runner.py b/mas_modes/runner.py index cf15330..735bf08 100644 --- a/mas_modes/runner.py +++ b/mas_modes/runner.py @@ -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: diff --git a/tests/test_differential.py b/tests/test_differential.py new file mode 100644 index 0000000..81c4238 --- /dev/null +++ b/tests/test_differential.py @@ -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] diff --git a/tests/test_js_impl.py b/tests/test_js_impl.py new file mode 100644 index 0000000..64314aa --- /dev/null +++ b/tests/test_js_impl.py @@ -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 diff --git a/tests/test_parser_conformance.py b/tests/test_parser_conformance.py index cc7b85a..9357800 100644 --- a/tests/test_parser_conformance.py +++ b/tests/test_parser_conformance.py @@ -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 diff --git a/workplans/MAS-WP-0009-declare-v0-2-final.md b/workplans/MAS-WP-0009-declare-v0-2-final.md index c307dbb..4098549 100644 --- a/workplans/MAS-WP-0009-declare-v0-2-final.md +++ b/workplans/MAS-WP-0009-declare-v0-2-final.md @@ -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.