From 59815701c18086b794931326482d965c71e477f9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 00:24:43 +0200 Subject: [PATCH] Implement MAS v0.1 reference experiment (MAS-WP-0002 M0-M8, M9 first pass) - mas_core: P2-maximal parser with required_profile, generator, statute schema + semantic lint + defaults-only inheritance, exact-host discovery with typed failures and same-origin redirects, pure policy resolver - mas_conformance: vendored spec fixtures (drift-tested) + property and statute cases - mas CLI: parse / explain / discover / lint-statute / test - mas-gateway: generic mail -> MAS event -> HTTP (MTA pipe + webhook) - examples: static great-service.example statute, reference ticket service - docs/experiment-review.md: first-pass findings; live MTA test still open Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 623586@bnt-lap001 Assistant-Session: 10b30928-5b3b-4c11-9934-5ecca07367d8 --- AGENTS.md | 13 + README.md | 40 ++- docs/experiment-review.md | 116 +++++++ .../great-service.example/.well-known/mas | 35 ++ examples/statute/serve.py | 37 ++ examples/ticket_service/ticket_service.py | 226 +++++++++++++ mas_cli/__init__.py | 0 mas_cli/__main__.py | 5 + mas_cli/main.py | 217 ++++++++++++ mas_conformance/__init__.py | 34 ++ mas_conformance/addresses.json | 315 ++++++++++++++++++ mas_conformance/properties.json | 270 +++++++++++++++ mas_conformance/runner.py | 50 +++ mas_conformance/statutes/cases.json | 122 +++++++ .../statutes/schema-inherits-http.json | 31 ++ .../statutes/schema-missing-equivalence.json | 26 ++ mas_conformance/statutes/schema-p3.json | 31 ++ .../statutes/schema-resource-type-case.json | 33 ++ mas_conformance/statutes/schema-version.json | 30 ++ .../statutes/semantic-inherits-self.json | 31 ++ .../statutes/semantic-p2-disabled-modes.json | 30 ++ .../statutes/semantic-registry-collision.json | 33 ++ .../statutes/semantic-registry-facet.json | 33 ++ .../statutes/semantic-registry-resource.json | 33 ++ .../statutes/semantic-registry-syntax.json | 33 ++ mas_conformance/statutes/valid-example.json | 54 +++ .../statutes/valid-great-service.json | 30 ++ .../statutes/warning-sequential.json | 34 ++ mas_core/__init__.py | 8 + mas_core/discovery.py | 256 ++++++++++++++ mas_core/generator.py | 38 +++ mas_core/model.py | 131 ++++++++ mas_core/parser.py | 167 ++++++++++ mas_core/policy.py | 165 +++++++++ mas_core/schema/mas-statute-v0.1.schema.json | 156 +++++++++ mas_core/statute.py | 258 ++++++++++++++ mas_gateway/__init__.py | 0 mas_gateway/core.py | 135 ++++++++ mas_gateway/main.py | 114 +++++++ pyproject.toml | 28 ++ tests/test_cli_and_static_statute.py | 82 +++++ tests/test_discovery.py | 150 +++++++++ tests/test_end_to_end.py | 164 +++++++++ tests/test_parser_conformance.py | 113 +++++++ tests/test_policy.py | 97 ++++++ tests/test_statute.py | 77 +++++ workplans/MAS-WP-0001-statehub-bootstrap.md | 6 +- workplans/MAS-WP-0002-reference-experiment.md | 24 +- 48 files changed, 4096 insertions(+), 15 deletions(-) create mode 100644 docs/experiment-review.md create mode 100644 examples/statute/great-service.example/.well-known/mas create mode 100644 examples/statute/serve.py create mode 100644 examples/ticket_service/ticket_service.py create mode 100644 mas_cli/__init__.py create mode 100644 mas_cli/__main__.py create mode 100644 mas_cli/main.py create mode 100644 mas_conformance/__init__.py create mode 100644 mas_conformance/addresses.json create mode 100644 mas_conformance/properties.json create mode 100644 mas_conformance/runner.py create mode 100644 mas_conformance/statutes/cases.json create mode 100644 mas_conformance/statutes/schema-inherits-http.json create mode 100644 mas_conformance/statutes/schema-missing-equivalence.json create mode 100644 mas_conformance/statutes/schema-p3.json create mode 100644 mas_conformance/statutes/schema-resource-type-case.json create mode 100644 mas_conformance/statutes/schema-version.json create mode 100644 mas_conformance/statutes/semantic-inherits-self.json create mode 100644 mas_conformance/statutes/semantic-p2-disabled-modes.json create mode 100644 mas_conformance/statutes/semantic-registry-collision.json create mode 100644 mas_conformance/statutes/semantic-registry-facet.json create mode 100644 mas_conformance/statutes/semantic-registry-resource.json create mode 100644 mas_conformance/statutes/semantic-registry-syntax.json create mode 100644 mas_conformance/statutes/valid-example.json create mode 100644 mas_conformance/statutes/valid-great-service.json create mode 100644 mas_conformance/statutes/warning-sequential.json create mode 100644 mas_core/__init__.py create mode 100644 mas_core/discovery.py create mode 100644 mas_core/generator.py create mode 100644 mas_core/model.py create mode 100644 mas_core/parser.py create mode 100644 mas_core/policy.py create mode 100644 mas_core/schema/mas-statute-v0.1.schema.json create mode 100644 mas_core/statute.py create mode 100644 mas_gateway/__init__.py create mode 100644 mas_gateway/core.py create mode 100644 mas_gateway/main.py create mode 100644 pyproject.toml create mode 100644 tests/test_cli_and_static_statute.py create mode 100644 tests/test_discovery.py create mode 100644 tests/test_end_to_end.py create mode 100644 tests/test_parser_conformance.py create mode 100644 tests/test_policy.py create mode 100644 tests/test_statute.py diff --git a/AGENTS.md b/AGENTS.md index 3f7eed6..86eb0ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,19 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ --- +## Developer Workflow + +```bash +uv venv && uv pip install -e '.[test]' # install (only runtime dep: jsonschema) +.venv/bin/pytest # full suite; offline apart from loopback fixture servers +.venv/bin/mas test # shared conformance corpus only +``` + +`mas-v0.1-spec/` is the normative bundle. `mas_conformance/addresses.json` and +`mas_core/schema/` are vendored copies, and tests fail if they drift from the +bundle. Keep the boundaries: the parser does no I/O, policy is pure, and only +`mas_core/discovery.py` touches the network. + ## Session Protocol **Start:** diff --git a/README.md b/README.md index 7f8cdf5..7da3a3c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ # mail-address-semantics -Mail Address Semantics is a voluntary convention for naming people, actors and functions, and for addressing resources, facets, and optional interaction preferences through ordinary email addresses while keeping classification, authority, authentication, equivalence, and delivery implementation explicit and external to the address syntax. \ No newline at end of file +Mail Address Semantics is a voluntary convention for naming people, actors and functions, and for addressing resources, facets, and optional interaction preferences through ordinary email addresses while keeping classification, authority, authentication, equivalence, and delivery implementation explicit and external to the address syntax. + +This repo holds the normative MAS v0.1 bundle (`mas-v0.1-spec/`) and a Python +reference experiment (see `INTENT.md` and `workplans/MAS-WP-0002-*`). + +> Parsing is local and deterministic. Discovery is networked. Policy is pure. Delivery remains external. + +## Layout + +| Path | Role | +|------|------| +| `mas_core/` | `parser`, `model`, `generator`, `statute` (schema + semantic lint + inheritance), `discovery`, `policy` | +| `mas_conformance/` | Shared corpus: spec fixtures (vendored, drift-tested), property cases, statute cases | +| `mas_cli/` | `mas parse / explain / discover / lint-statute / test` | +| `mas_gateway/` | Generic inbound mail → MAS event → HTTP (`pipe` for MTAs, `webhook` for providers) | +| `examples/statute/` | Static `/.well-known/mas` for `great-service.example` + tiny server | +| `examples/ticket_service/` | Reference app proving create / correlate / closed / unknown | +| `docs/experiment-review.md` | Milestone 9 findings | + +## Quick start + +```bash +uv venv && uv pip install -e '.[test]' +.venv/bin/pytest # full suite, offline +.venv/bin/mas test # conformance corpus +.venv/bin/mas explain 'ticket!callback@great-service.example' \ + --statute examples/statute/great-service.example/.well-known/mas +.venv/bin/mas lint-statute examples/statute/great-service.example/.well-known/mas +``` + +Local end-to-end run: + +```bash +python examples/ticket_service/ticket_service.py --db /tmp/t.sqlite3 & # :8025 +mas-gateway --statute examples/statute/great-service.example/.well-known/mas \ + pipe --recipient 'ticket!callback@great-service.example' \ + --app-url http://127.0.0.1:8025/mas-events < message.eml +curl -s http://127.0.0.1:8025/outbox +``` diff --git a/docs/experiment-review.md b/docs/experiment-review.md new file mode 100644 index 0000000..74ea403 --- /dev/null +++ b/docs/experiment-review.md @@ -0,0 +1,116 @@ +# MAS v0.1 experiment review (first pass, 2026-10-08) + +Status of MAS-WP-0002, Milestone 9. The answers below come from building +`mas_core`, the `mas` CLI, `mas-gateway` and the reference ticket service, +and from running the test suite (135 tests, all offline except the +loopback fixture servers). + +**Scope caveat:** every "email path" here is simulated. Raw RFC 5322 +messages go through the real `mas-gateway pipe` entrypoint, but no +production MTA or mailbox provider was involved. The *Email infrastructure* +question is therefore still open and needs the live test described at the +end. + +## Answers to the review questions + +### Syntax: did the parser stay simple? +Yes. The parser is about 100 lines with no I/O. It always uses the maximal +(P2) grammar and records `required_profile`, so `ticket!callback` parses +without knowing anything about the namespace. All 31 spec fixtures and 27 +added property cases pass. + +Gaps found in the spec: +- **Resource-type case.** The ABNF allows `Ticket=PR42`, but the statute + schema only registers lower-case type names. Such an address parses, but + it can never match a declared type, even when the statute says + `ascii-insensitive`. The spec should either make resource types + lower-case in the grammar, or state that the equivalence policy also + applies to the type name. +- **Interaction-mode names.** The value grammar allows `call.back` and + `a=b`. The schema's mode-name pattern does not. Those modes parse but can + never be enabled. This is harmless, but it should be documented. +- **Registry keys.** The schema pattern already blocks `+ ! $ ^`, but it + still allows `=` and leading or trailing dots. Semantic lint is needed to + reject a key like `ticket=GS-1` (`registry-key-resource`). + +### Discovery: was exact-host metadata sufficient? +For this experiment, yes. Implementation choices the spec does not settle: +- `application/json` (or `+json`) is required as the content type + (`InvalidContentType`). The spec only asks for 2xx plus the schema. It + should say whether the content type is checked. +- Redirects are followed only when scheme, host and port all match. That + is stricter than "same host". A port change counts as `CrossHostRedirect` + and an https→http change as `SchemeDowngrade`. +- `mas_version` is checked before the schema runs, so a future version is + reported as `UnsupportedVersion` and not as a schema error. +- **Operational finding:** a service must not depend on discovering *its + own* statute. If the statute endpoint is down, the ticket service sees + `namespace_confirmed = false` and correctly refuses to act. The mail is + then declined. Operators should pin their own statute in local + configuration (`mas-gateway --statute`). Discovery is for third-party + namespaces. + +### Policy: could all inference stay explicit? +Yes. Policy is a pure function. Without a statute, `kind` is always +`opaque`, `=` alone gives no resource semantics, and `!` stays +"unconfirmed". `mas explain` lists each of these as an *unknown*. +Folding is applied only when a statute declares it, and dot folding never +touches resource identifiers. + +Facets have no statute declaration at all, so they are always reported as +"application-defined". A later version could let statutes list the facets +they recognise. + +### Email infrastructure: which forms were rewritten or rejected? +**Not yet tested.** Known risks to check: +- `=` overlaps with SRS (`SRS0=...`) and VERP encodings. +- `!` is UUCP bang-path syntax. Some validators and web forms reject it. +- Postfix `recipient_delimiter = +` strips the facet for lookups. That fits + the facet-degradation design. + +### Resource addressing: did it improve correlation? +Yes. The service replies *from* `ticket=GS-…@great-service.example`, so an +ordinary reply lands on the resource address. Correlation uses the address +only. The tests send an unrelated subject and a facet-stripped address, +and both correlate. + +### Subject lines: could identifiers disappear? +Yes. Replies use the customer's own subject, and no ticket id is ever put +in it (asserted in the tests). + +### Lifecycle: did closed resources stay understandable? +Yes. A closed ticket stays addressable. Mail to it is archived separately +and the ticket is not changed. A single notice goes only to the original +requester, and never to automated senders (`Auto-Submitted`, +`Precedence`). Mail to an unknown resource is quarantined with no reply. +For an MTA pipe, the gateway exits 0 when the app declines and 75 when the +app is unreachable, so it never generates a bounce (no backscatter). + +### Interaction: was `!callback` useful? +Partly. It expresses intent cleanly, but the service still needs a phone +number and a time. The address cannot carry those, so the first reply has +to ask by email. `!callback` therefore starts a negotiation; it does not +replace one. + +The bare `ticket` in `ticket!callback` is an **opaque** target. It has no +defined link to the resource type `ticket`. The spec's canonical example +(§7) implies such a link. The statute has to register `ticket` as an +intake function, and the service has to know what that means. The spec +should either say this explicitly, or define "type intake" semantics for +an opaque target that names a declared resource type. + +### Security: did any address start acting like a capability? +Not for reading or control. Knowing a ticket address grants no read access +and no admin action. It does let anyone **append correspondence** to an +open ticket, which is the intended function of a communication endpoint. +That is also an injection and spam vector. Ticket ids are non-guessable +(10 Crockford base32 characters), but sender policy belongs to the +application, not to MAS. + +## Recommended next step (before the decision gate) +Run the live path: a real domain, the static statute served at +`/.well-known/mas`, Postfix with the `mas-gateway pipe` transport, and test +mail sent from Gmail, Outlook and one corporate gateway to +`ticket!callback@`, `ticket=ID@` and `ticket=ID+comments@`. Record which +addresses arrive unchanged. That evidence answers the open +*Email infrastructure* question. diff --git a/examples/statute/great-service.example/.well-known/mas b/examples/statute/great-service.example/.well-known/mas new file mode 100644 index 0000000..c48ad1a --- /dev/null +++ b/examples/statute/great-service.example/.well-known/mas @@ -0,0 +1,35 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "ticket": { + "kind": "function", + "description": "Ticket intake; '!MODE' requests a continuation mode" + }, + "support": { + "kind": "function", + "description": "General support intake" + } + } +} diff --git a/examples/statute/serve.py b/examples/statute/serve.py new file mode 100644 index 0000000..444469f --- /dev/null +++ b/examples/statute/serve.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Serve a static MAS statute directory for local experiments. + + python examples/statute/serve.py examples/statute/great-service.example --port 8080 + +This is deliberately not a statute server framework: it is a static file +server that labels `/.well-known/mas` as application/json, which +`mas discover` requires. Production deployments should use their normal web +server with an equivalent rule, e.g. for nginx: + + location = /.well-known/mas { default_type application/json; } +""" + +import argparse +import functools +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + + +class StatuteHandler(SimpleHTTPRequestHandler): + def guess_type(self, path): + if path.rstrip("/").endswith("/.well-known/mas"): + return "application/json" + return super().guess_type(path) + + +def make_server(root: str, host: str = "127.0.0.1", port: int = 8080) -> ThreadingHTTPServer: + return ThreadingHTTPServer((host, port), functools.partial(StatuteHandler, directory=root)) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("root") + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=8080) + a = ap.parse_args() + print(f"serving {a.root} on http://{a.host}:{a.port}/.well-known/mas") + make_server(a.root, a.host, a.port).serve_forever() diff --git a/examples/ticket_service/ticket_service.py b/examples/ticket_service/ticket_service.py new file mode 100644 index 0000000..5d79ead --- /dev/null +++ b/examples/ticket_service/ticket_service.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Reference ticket service for the MAS v0.1 experiment. + +Consumes normalized MAS events from `mas-gateway` (POST /mas-events) and +proves the canonical lifecycle: + + A create ticket!callback@DOMAIN -> new ticket, reply from ticket=ID@DOMAIN + B correlate ticket=ID[+facet]@DOMAIN -> message attached to ID; subject ignored + C closed ticket=ID@DOMAIN (closed) -> archived, ticket not mutated, one notice + D unknown ticket=NOPE@DOMAIN -> quarantined, no reply (no backscatter) + +Resource addresses are correlation identifiers, not credentials: knowing +one lets you send mail to the ticket, nothing more. Admin endpoints here are +unauthenticated and meant for local experiments only. +""" + +from __future__ import annotations + +import argparse +import json +import secrets +import smtplib +import sqlite3 +import threading +from email.message import EmailMessage +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Callable, Optional + +from mas_core.generator import resource_local_part + +RESOURCE_TYPE = "ticket" +INTAKE_TARGET = "ticket" +_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford base32, no I L O U + + +def default_ticket_id() -> str: + """Non-guessable-preferred, per the great-service statute.""" + return "GS-" + "".join(secrets.choice(_ALPHABET) for _ in range(10)) + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS tickets ( + id TEXT PRIMARY KEY, domain TEXT NOT NULL, status TEXT NOT NULL, + requester TEXT, subject TEXT, requested_interaction TEXT, created_seq INTEGER +); +CREATE TABLE IF NOT EXISTS messages ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, disposition TEXT NOT NULL, + recipient TEXT, facet TEXT, sender TEXT, subject TEXT, message_id TEXT, body TEXT +); +CREATE TABLE IF NOT EXISTS outbox ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, mail_from TEXT, mail_to TEXT, + subject TEXT, body TEXT, in_reply_to TEXT, sent INTEGER DEFAULT 0 +); +""" + + +class TicketService: + def __init__(self, db_path: str = ":memory:", id_factory: Callable[[], str] = default_ticket_id, + smtp: Optional[tuple[str, int]] = None, closed_notice: bool = True): + self.db = sqlite3.connect(db_path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.executescript(SCHEMA) + self.lock = threading.Lock() + self.new_id = id_factory + self.smtp = smtp + self.closed_notice = closed_notice + + # -- event handling ------------------------------------------------------------- + + def handle(self, event: dict) -> tuple[int, dict]: + mas, msg = event.get("mas", {}), event.get("message", {}) + with self.lock, self.db: + if not mas.get("valid"): + return 422, {"status": "not-mas", "action": "ignored"} + if not mas.get("namespace_confirmed"): + # No validated statute: we refuse to act on unconfirmed semantics. + return 422, {"status": "namespace-unconfirmed", "action": "ignored", + "discovery": mas.get("discovery")} + if mas.get("kind") == "resource" and mas.get("resource_type") == RESOURCE_TYPE: + return self._to_resource(event, mas, msg) + if mas.get("target_syntax") == "opaque" and mas.get("target") == INTAKE_TARGET: + return self._create(event, mas, msg) + return 404, {"status": "not-handled", "action": "ignored"} + + def _create(self, event, mas, msg): + interaction = mas.get("interaction") + failures = {f["code"] for f in mas.get("failures", [])} + honored = interaction if interaction and not failures else None + tid = self.new_id() + domain = mas["domain"] + self.db.execute("INSERT INTO tickets VALUES (?,?,?,?,?,?,NULL)", + (tid, domain, "open", msg.get("from"), msg.get("subject"), honored)) + self._store(tid, "attached", event, mas, msg) + address = f"{resource_local_part(RESOURCE_TYPE, tid)}@{domain}" + lines = [f"Your request is now ticket {tid}.", + f"Reply to {address} to add to it; no reference in the subject is needed."] + if honored: + lines.append(f"You asked to continue via '{honored}'. We will try to honour that preference.") + elif interaction: + lines.append(f"The requested continuation mode '{interaction}' is not available " + f"({', '.join(sorted(failures))}); we will continue by email.") + self._reply(tid, address, msg, msg.get("subject") or "Your request", "\n".join(lines)) + return 201, {"status": "created", "ticket_id": tid, "resource_address": address, + "requested_interaction": honored} + + def _to_resource(self, event, mas, msg): + tid = mas["resource_id"] + row = self.db.execute("SELECT * FROM tickets WHERE id=? AND domain=?", + (tid, mas["domain"])).fetchone() + if row is None: + self._store(None, "quarantined-unknown", event, mas, msg) + return 202, {"status": "unknown-resource", "action": "quarantined, no reply"} + if row["status"] == "closed": + self._store(tid, "archived-after-close", event, mas, msg) + notified = False + if self.closed_notice and msg.get("from") and msg.get("from") == row["requester"]: + address = f"{resource_local_part(RESOURCE_TYPE, tid)}@{row['domain']}" + notified = self._reply(tid, address, msg, msg.get("subject") or "Ticket closed", + f"Ticket {tid} is closed. Your message was archived and did " + f"not reopen it. Write to {INTAKE_TARGET}@{row['domain']} " + f"for a new request.") + return 200, {"status": "closed", "ticket_id": tid, "action": "archived", + "mutated": False, "notified": notified} + self._store(tid, "attached", event, mas, msg) + return 200, {"status": "correlated", "ticket_id": tid, "facet": mas.get("facet")} + + def _store(self, tid, disposition, event, mas, msg): + self.db.execute( + "INSERT INTO messages (ticket_id, disposition, recipient, facet, sender, subject, message_id, body)" + " VALUES (?,?,?,?,?,?,?,?)", + (tid, disposition, event.get("recipient"), mas.get("facet"), msg.get("from"), + msg.get("subject"), msg.get("message_id"), msg.get("text"))) + + def _reply(self, tid, mail_from, msg, subject, body) -> bool: + to = msg.get("from") + auto = (msg.get("auto_submitted") or "no").lower() != "no" + bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk") + if not to or auto or bulk: + return False # never auto-reply to automated mail + if not subject.lower().startswith("re:"): + subject = "Re: " + subject + self.db.execute("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to)" + " VALUES (?,?,?,?,?,?)", (tid, mail_from, to, subject, body, msg.get("message_id"))) + if self.smtp: + self._send(mail_from, to, subject, body, msg.get("message_id")) + return True + + def _send(self, mail_from, to, subject, body, in_reply_to): + m = EmailMessage() + m["From"], m["To"], m["Subject"] = mail_from, to, subject + m["Auto-Submitted"] = "auto-replied" + if in_reply_to: + m["In-Reply-To"] = m["References"] = in_reply_to + m.set_content(body) + with smtplib.SMTP(*self.smtp) as s: + s.send_message(m) + + # -- admin / inspection ---------------------------------------------------------- + + def close(self, tid: str) -> bool: + with self.lock, self.db: + return self.db.execute("UPDATE tickets SET status='closed' WHERE id=?", (tid,)).rowcount == 1 + + def ticket(self, tid: str) -> Optional[dict]: + row = self.db.execute("SELECT * FROM tickets WHERE id=?", (tid,)).fetchone() + if row is None: + return None + msgs = self.db.execute("SELECT * FROM messages WHERE ticket_id=? ORDER BY seq", (tid,)).fetchall() + return {**dict(row), "messages": [dict(m) for m in msgs]} + + def outbox(self) -> list[dict]: + return [dict(r) for r in self.db.execute("SELECT * FROM outbox ORDER BY seq")] + + def quarantine(self) -> list[dict]: + return [dict(r) for r in self.db.execute( + "SELECT * FROM messages WHERE disposition='quarantined-unknown' ORDER BY seq")] + + +def make_server(service: TicketService, host="127.0.0.1", port=8025) -> ThreadingHTTPServer: + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.path == "/mas-events": + try: + event = json.loads(body.decode()) + except (UnicodeDecodeError, json.JSONDecodeError): + return self._json(400, {"error": "invalid JSON"}) + return self._json(*service.handle(event)) + parts = self.path.strip("/").split("/") + if len(parts) == 3 and parts[0] == "tickets" and parts[2] == "close": + return self._json(200 if service.close(parts[1]) else 404, {"ticket_id": parts[1]}) + self._json(404, {"error": "not found"}) + + def do_GET(self): + parts = self.path.strip("/").split("/") + if len(parts) == 2 and parts[0] == "tickets": + t = service.ticket(parts[1]) + return self._json(200 if t else 404, t or {"error": "not found"}) + if parts == ["outbox"]: + return self._json(200, service.outbox()) + self._json(404, {"error": "not found"}) + + def _json(self, status, obj): + out = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + + def log_message(self, *a): + pass + + return ThreadingHTTPServer((host, port), Handler) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--db", default="tickets.sqlite3") + ap.add_argument("--listen", default="127.0.0.1:8025") + ap.add_argument("--smtp", metavar="HOST:PORT", help="send replies via this SMTP relay") + a = ap.parse_args() + smtp = (a.smtp.rsplit(":", 1)[0], int(a.smtp.rsplit(":", 1)[1])) if a.smtp else None + h, p = a.listen.rsplit(":", 1) + print(f"ticket service on http://{a.listen}/mas-events") + make_server(TicketService(a.db, smtp=smtp), h, int(p)).serve_forever() diff --git a/mas_cli/__init__.py b/mas_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mas_cli/__main__.py b/mas_cli/__main__.py new file mode 100644 index 0000000..ac7e395 --- /dev/null +++ b/mas_cli/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .main import main + +sys.exit(main()) diff --git a/mas_cli/main.py b/mas_cli/main.py new file mode 100644 index 0000000..efa1002 --- /dev/null +++ b/mas_cli/main.py @@ -0,0 +1,217 @@ +"""`mas` — inspection CLI for Mail Address Semantics v0.1.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from mas_core import MAS_VERSION, __version__ +from mas_core.discovery import Discovered, discover +from mas_core.parser import MasSyntaxError, parse_address, parse_local_part, validate_profile +from mas_core.policy import resolve +from mas_core.statute import ERROR, SCHEMA, build_statute, lint + + +def _out(args, data, text): + print(json.dumps(data, indent=2) if args.json else text) + + +def _parse_any(value): + return parse_address(value) if "@" in value else parse_local_part(value) + + +def _syntax_lines(d): + lines = [f" target: {d['target']}", f" target syntax: {d['target_kind']}"] + if d["target_kind"] == "resource": + lines += [f" resource type: {d['resource_type']}", f" resource id: {d['resource_id']}"] + if d["facet"]: + lines.append(f" facet: {d['facet']} (base if stripped: {d['base']})") + if d["interaction"]: + lines.append(f" interaction: {d['interaction']}") + lines.append(f" required profile: {d['required_profile']}") + if not d["generator_allowed"]: + lines.append(" note: valid to parse, but a v0.1 generator must not emit '+' with '!'") + return lines + + +def cmd_parse(args): + try: + p = _parse_any(args.address) + except MasSyntaxError as e: + _out(args, {"valid": False, "error": e.code, "message": str(e)}, + f"invalid MAS v0.1 syntax [{e.code}]: {e}") + return 1 + d = p.to_dict() + if args.profile: + r = validate_profile(p, args.profile) + d["profile_check"] = {"profile": args.profile.upper(), "ok": r.ok, "error": r.code} + if not r: + _out(args, {"valid": False, **d}, f"invalid under {args.profile.upper()} [{r.code}]: {r.message}") + return 1 + _out(args, {"valid": True, **d}, "Syntax:\n" + "\n".join(_syntax_lines(d))) + return 0 + + +def _discover(args, host): + if args.statute: + doc = json.loads(Path(args.statute).read_text()) + return Discovered(host, f"file:{args.statute}", build_statute(doc, host)) + return discover(host, scheme="http" if args.insecure_http else "https", port=args.port) + + +def cmd_discover(args): + r = _discover(args, args.host) + if r.ok: + st = r.statute + text = [f"Statute for {r.host} ({r.url}): valid", + f" profiles: P1, P2={'enabled' if st.p2 else 'disabled'}", + f" resource types: {', '.join(k for k in st.resource_types if st.resource_type_enabled(k)) or '-'}", + f" interaction modes: {', '.join(k for k in st.interaction_modes if st.interaction_mode_enabled(k)) or '-'}", + f" equivalence: case={st.local_part_case}, dot_folding={st.dot_folding}", + f" registry entries: {len(st.addresses)}"] + if st.inherited_from: + text.append(f" inherited defaults from: {', '.join(st.inherited_from)}") + _out(args, r.to_dict(), "\n".join(text)) + return 0 + _out(args, r.to_dict(), f"No valid MAS statute for {r.host}\n failure: {r.kind.value}\n" + f" url: {r.url}\n detail: {r.detail}") + return 2 + + +def cmd_explain(args): + try: + addr = parse_address(args.address) + except MasSyntaxError as e: + _out(args, {"address": args.address, "syntax": {"valid": False, "error": e.code}}, + f"Address:\n {args.address}\n\nSyntax:\n invalid MAS v0.1 syntax [{e.code}]: {e}\n" + " (the address may still be deliverable as ordinary email)") + return 1 + registry = json.loads(Path(args.registry).read_text()) if args.registry else None + disc = None if args.no_discover else _discover(args, addr.domain) + statute = disc.statute if disc is not None and disc.ok else None + sem = resolve(addr, statute, registry) + syn = addr.to_dict() + lines = ["Address:", f" {addr}", "", "Syntax:", *_syntax_lines(syn), "", "Discovery:"] + if disc is None: + lines.append(" skipped (--no-discover)") + elif disc.ok: + lines.append(f" valid statute for {disc.host} ({disc.url})") + else: + lines += [" no valid MAS statute found", f" failure: {disc.kind.value} — {disc.detail}"] + lines += ["", "Semantics:", f" kind: {sem.kind}" + (f" (from {sem.kind_source})" if sem.kind_source != "none" else "")] + if sem.registry_key: + lines.append(f" registry entry: {sem.registry_key}") + if sem.interaction and statute is not None and sem.ok: + lines.append(f" interaction preference: {sem.interaction} (a request, not a command)") + for f in sem.failures: + lines.append(f" failure: {f.code} — {f.message}") + if sem.unknowns: + lines += ["", "Unknowns:"] + [f" - {u}" for u in sem.unknowns] + data = {"address": str(addr), "syntax": syn, + "discovery": None if disc is None else disc.to_dict(), "semantics": sem.to_dict()} + _out(args, data, "\n".join(lines)) + return 0 + + +def cmd_lint(args): + worst = 0 + reports = [] + for file in args.files: + try: + doc = json.loads(Path(file).read_text()) + except (OSError, json.JSONDecodeError) as e: + reports.append({"file": file, "findings": [{"layer": SCHEMA, "severity": ERROR, + "code": "unreadable", "path": "", "message": str(e)}]}) + worst = 1 + continue + findings = lint(doc, args.host) + reports.append({"file": file, "findings": [f.__dict__ for f in findings]}) + if any(f.severity == ERROR for f in findings): + worst = 1 + if args.json: + print(json.dumps(reports, indent=2)) + return worst + for rep in reports: + fs = rep["findings"] + errs = [f for f in fs if f["severity"] == ERROR] + print(f"{rep['file']}: {'INVALID' if errs else 'OK'}" + f" ({len(errs)} error(s), {len(fs) - len(errs)} warning(s))") + for f in fs: + label = "warning" if f["severity"] != ERROR else f"{f['layer']} error" + print(f" {label} [{f['code']}] {f['path'] or '/'}: {f['message']}") + return worst + + +def cmd_test(args): + from mas_conformance import statute_cases, statute_document + from mas_conformance.runner import run_parser_corpus + rep = run_parser_corpus() + st_fail = [] + for c in statute_cases(): + fs = lint(statute_document(c["file"]), c["host"]) + errs = [f for f in fs if f.severity == ERROR] + good = (not errs) if c["expect"] == "valid" else ( + errs and {f.layer for f in errs} == {c["expect"]} + and set(c.get("codes", [])) <= {f.code for f in errs}) + if not good: + st_fail.append((c["id"], c["file"], [str(f) for f in errs])) + n_st = len(statute_cases()) + print(f"parser corpus: {rep.passed}/{rep.passed + len(rep.failures)} passed") + for fid, lp, why in rep.failures: + print(f" FAIL {fid} {lp!r}: {why}") + print(f"statute corpus: {n_st - len(st_fail)}/{n_st} passed") + for fid, file, why in st_fail: + print(f" FAIL {fid} {file}: {why}") + return 0 if rep.ok and not st_fail else 1 + + +def build_parser(): + ap = argparse.ArgumentParser(prog="mas", description=f"Mail Address Semantics v{MAS_VERSION} tools") + ap.add_argument("--version", action="version", version=f"mas {__version__} (MAS {MAS_VERSION})") + sub = ap.add_subparsers(dest="cmd", required=True) + + def common(p): + p.add_argument("--json", action="store_true", help="machine-readable output") + return p + + def disc_opts(p): + p.add_argument("--statute", metavar="FILE", help="use a local statute file instead of discovery") + p.add_argument("--insecure-http", action="store_true", help=argparse.SUPPRESS) + p.add_argument("--port", type=int, help=argparse.SUPPRESS) + + p = common(sub.add_parser("parse", help="show syntax only (no network)")) + p.add_argument("address", help="local-part or full address") + p.add_argument("--profile", choices=["p1", "p2", "P1", "P2"], help="check a parser conformance class") + p.set_defaults(func=cmd_parse) + + p = common(sub.add_parser("discover", help="fetch and validate https://HOST/.well-known/mas")) + p.add_argument("host") + disc_opts(p) + p.set_defaults(func=cmd_discover) + + p = common(sub.add_parser("explain", help="syntax, discovery and semantics, kept separate")) + p.add_argument("address") + disc_opts(p) + p.add_argument("--no-discover", action="store_true", help="skip discovery (syntax-only semantics)") + p.add_argument("--registry", metavar="FILE", help="local registry JSON (addresses/resource_types)") + p.set_defaults(func=cmd_explain) + + p = common(sub.add_parser("lint-statute", help="schema + semantic validation of statute files")) + p.add_argument("files", nargs="+", metavar="FILE") + p.add_argument("--host", help="host the statute is meant for (checks namespace)") + p.set_defaults(func=cmd_lint) + + p = sub.add_parser("test", help="run the bundled conformance corpus") + p.set_defaults(func=cmd_test) + return ap + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mas_conformance/__init__.py b/mas_conformance/__init__.py new file mode 100644 index 0000000..61597a7 --- /dev/null +++ b/mas_conformance/__init__.py @@ -0,0 +1,34 @@ +"""MAS v0.1 conformance corpus. + +`addresses.json` is vendored unchanged from the spec bundle +(`mas-v0.1-spec/conformance/addresses.json`) and is normative. +`properties.json` adds implementation-independent property and boundary +cases. `statutes/` holds statute validation cases. Every language +implementation should consume these files unchanged. +""" + +from __future__ import annotations + +import json +from importlib import resources + + +def _load(name: str): + return json.loads(resources.files(__name__).joinpath(name).read_text(encoding="utf-8")) + + +def address_fixtures() -> list[dict]: + return _load("addresses.json") + + +def property_fixtures() -> list[dict]: + return _load("properties.json") + + +def statute_cases() -> list[dict]: + """Each case: {id, file, host, expect: "valid"|"schema"|"semantic", codes?}.""" + return _load("statutes/cases.json") + + +def statute_document(file: str) -> dict: + return _load(f"statutes/{file}") diff --git a/mas_conformance/addresses.json b/mas_conformance/addresses.json new file mode 100644 index 0000000..00afc69 --- /dev/null +++ b/mas_conformance/addresses.json @@ -0,0 +1,315 @@ +[ + { + "id": "p1-001", + "local_part": "bernd.worsch", + "profile": "p1", + "valid": true, + "parsed": { + "target": "bernd.worsch", + "target_kind": "opaque", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-002", + "local_part": "anne-marie.schmidt", + "profile": "p1", + "valid": true, + "parsed": { + "target": "anne-marie.schmidt", + "target_kind": "opaque", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-003", + "local_part": "support", + "profile": "p1", + "valid": true, + "parsed": { + "target": "support", + "target_kind": "opaque", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-004", + "local_part": "invoice-reviewer_backup", + "profile": "p1", + "valid": true, + "parsed": { + "target": "invoice-reviewer_backup", + "target_kind": "opaque", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-005", + "local_part": "happy-helper.v2", + "profile": "p1", + "valid": true, + "parsed": { + "target": "happy-helper.v2", + "target_kind": "opaque", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-010", + "local_part": "ticket=PR42", + "profile": "p1", + "valid": true, + "parsed": { + "target": "ticket=PR42", + "target_kind": "resource", + "resource_type": "ticket", + "resource_id": "PR42", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-011", + "local_part": "invoice=RE=2026-4711", + "profile": "p1", + "valid": true, + "parsed": { + "target": "invoice=RE=2026-4711", + "target_kind": "resource", + "resource_type": "invoice", + "resource_id": "RE=2026-4711", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-012", + "local_part": "ticket=PR42+comments", + "profile": "p1", + "valid": true, + "parsed": { + "target": "ticket=PR42", + "target_kind": "resource", + "resource_type": "ticket", + "resource_id": "PR42", + "facet": "comments", + "interaction": null + } + }, + { + "id": "p1-013", + "local_part": "bernd.worsch+github", + "profile": "p1", + "valid": true, + "parsed": { + "target": "bernd.worsch", + "target_kind": "opaque", + "facet": "github", + "interaction": null + } + }, + { + "id": "p1-020", + "local_part": "=PR42", + "profile": "p1", + "valid": false, + "error": "empty resource type" + }, + { + "id": "p1-021", + "local_part": "ticket=", + "profile": "p1", + "valid": false, + "error": "empty resource id" + }, + { + "id": "p1-022", + "local_part": "ticket=PR42+", + "profile": "p1", + "valid": false, + "error": "empty facet" + }, + { + "id": "p1-023", + "local_part": "ticket=PR42+comments+attachments", + "profile": "p1", + "valid": false, + "error": "multiple facet operators" + }, + { + "id": "p1-024", + "local_part": "ticket=PR42$4.99", + "profile": "p1", + "valid": false, + "error": "reserved operator" + }, + { + "id": "p1-025", + "local_part": "ticket=PR42^1h", + "profile": "p1", + "valid": false, + "error": "reserved operator" + }, + { + "id": "p1-026", + "local_part": ".bernd", + "profile": "p1", + "valid": false, + "error": "leading dot" + }, + { + "id": "p1-027", + "local_part": "bernd.", + "profile": "p1", + "valid": false, + "error": "trailing dot" + }, + { + "id": "p1-028", + "local_part": "bernd..worsch", + "profile": "p1", + "valid": false, + "error": "repeated dot" + }, + { + "id": "p1-029", + "local_part": "ticket=PR.42", + "profile": "p1", + "valid": true, + "parsed": { + "target": "ticket=PR.42", + "target_kind": "resource", + "resource_type": "ticket", + "resource_id": "PR.42", + "facet": null, + "interaction": null + } + }, + { + "id": "p1-030", + "local_part": "ticket=PR%2B42", + "profile": "p1", + "valid": true, + "parsed": { + "target": "ticket=PR%2B42", + "target_kind": "resource", + "resource_type": "ticket", + "resource_id": "PR%2B42", + "facet": null, + "interaction": null + }, + "note": "%2B is literal data; MAS v0.1 does not percent-decode" + }, + { + "id": "p2-001", + "local_part": "ticket!callback", + "profile": "p2", + "valid": true, + "parsed": { + "target": "ticket", + "target_kind": "opaque", + "facet": null, + "interaction": "callback" + }, + "generator_allowed": true + }, + { + "id": "p2-002", + "local_part": "ticket=PR42!web", + "profile": "p2", + "valid": true, + "parsed": { + "target": "ticket=PR42", + "target_kind": "resource", + "resource_type": "ticket", + "resource_id": "PR42", + "facet": null, + "interaction": "web" + }, + "generator_allowed": true + }, + { + "id": "p2-003", + "local_part": "ticket=PR42+comments!callback", + "profile": "p2", + "valid": true, + "parsed": { + "target": "ticket=PR42", + "target_kind": "resource", + "resource_type": "ticket", + "resource_id": "PR42", + "facet": "comments", + "interaction": "callback" + }, + "generator_allowed": false + }, + { + "id": "p2-010", + "local_part": "ticket!", + "profile": "p2", + "valid": false, + "error": "empty interaction" + }, + { + "id": "p2-011", + "local_part": "ticket!callback!chat", + "profile": "p2", + "valid": false, + "error": "multiple interaction operators" + }, + { + "id": "p2-012", + "local_part": "ticket!callback+comments", + "profile": "p2", + "valid": false, + "error": "non-canonical operator order" + }, + { + "id": "p1-031", + "local_part": "ticket!callback", + "profile": "p1", + "valid": false, + "error": "interaction not enabled in P1" + }, + { + "id": "p2-013", + "local_part": "ticket=PR42+comments!callback$4.99", + "profile": "p2", + "valid": false, + "error": "reserved operator" + }, + { + "id": "p2-014", + "local_part": "ticket=PR42+comments!call.back", + "profile": "p2", + "valid": true, + "parsed": { + "target": "ticket=PR42", + "target_kind": "resource", + "resource_type": "ticket", + "resource_id": "PR42", + "facet": "comments", + "interaction": "call.back" + }, + "generator_allowed": false + }, + { + "id": "p1-040", + "local_part": "\"ticket=PR42\"", + "profile": "p1", + "valid": false, + "error": "quoted local-part outside canonical MAS v0.1" + }, + { + "id": "p1-041", + "local_part": "tïcket=PR42", + "profile": "p1", + "valid": false, + "error": "non-ASCII outside canonical MAS v0.1" + } +] diff --git a/mas_conformance/properties.json b/mas_conformance/properties.json new file mode 100644 index 0000000..a1529c1 --- /dev/null +++ b/mas_conformance/properties.json @@ -0,0 +1,270 @@ +[ + { + "id": "prop-len-063", + "local_part": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "profile": "p1", + "valid": true, + "parsed": { + "target": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "target_kind": "opaque", + "facet": null, + "interaction": null + }, + "note": "63 octets" + }, + { + "id": "prop-len-064", + "local_part": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "profile": "p1", + "valid": true, + "parsed": { + "target": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "target_kind": "opaque", + "facet": null, + "interaction": null + }, + "note": "64 octets: exactly the SMTP limit" + }, + { + "id": "prop-len-065", + "local_part": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "profile": "p1", + "valid": false, + "error": "too-long", + "note": "65 octets exceeds the SMTP limit" + }, + { + "id": "prop-len-065-p2", + "local_part": "ticket=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx!callback", + "profile": "p2", + "valid": false, + "error": "too-long", + "note": "limit applies to the whole local-part incl. operators" + }, + { + "id": "prop-eq-001", + "local_part": "ticket==PR42", + "profile": "p1", + "valid": true, + "parsed": { + "target": "ticket==PR42", + "target_kind": "resource", + "facet": null, + "interaction": null, + "resource_type": "ticket", + "resource_id": "=PR42" + }, + "note": "only the first '=' is structural" + }, + { + "id": "prop-eq-002", + "local_part": "ticket=PR42=", + "profile": "p1", + "valid": true, + "parsed": { + "target": "ticket=PR42=", + "target_kind": "resource", + "facet": null, + "interaction": null, + "resource_type": "ticket", + "resource_id": "PR42=" + }, + "note": "trailing '=' is resource-id data" + }, + { + "id": "prop-eq-003", + "local_part": "bernd.worsch+a=b", + "profile": "p1", + "valid": true, + "parsed": { + "target": "bernd.worsch", + "target_kind": "opaque", + "facet": "a=b", + "interaction": null + }, + "note": "'=' is data inside a facet" + }, + { + "id": "prop-res-001", + "local_part": "$", + "profile": "p1", + "valid": false, + "error": "reserved-operator" + }, + { + "id": "prop-res-002", + "local_part": "support^", + "profile": "p1", + "valid": false, + "error": "reserved-operator" + }, + { + "id": "prop-res-003", + "local_part": "ticket!callback$", + "profile": "p2", + "valid": false, + "error": "reserved-operator" + }, + { + "id": "prop-res-004", + "local_part": "ticket!call^back", + "profile": "p2", + "valid": false, + "error": "reserved-operator" + }, + { + "id": "prop-p2-001", + "local_part": "ticket!callback", + "profile": "p2", + "valid": true, + "parsed": { + "target": "ticket", + "target_kind": "opaque", + "facet": null, + "interaction": "callback" + }, + "required_profile": "P2", + "generator_allowed": true, + "note": "maximal parser accepts; namespace support for P2 is a policy question" + }, + { + "id": "prop-p2-002", + "local_part": "support+urgent", + "profile": "p1", + "valid": true, + "parsed": { + "target": "support", + "target_kind": "opaque", + "facet": "urgent", + "interaction": null + }, + "required_profile": "P1", + "base": "support", + "note": "registry lookup operates on the base target 'support', never 'support+urgent'" + }, + { + "id": "prop-pct-001", + "local_part": "a%40b", + "profile": "p1", + "valid": true, + "parsed": { + "target": "a%40b", + "target_kind": "opaque", + "facet": null, + "interaction": null + }, + "note": "percent sequences are data, never decoded" + }, + { + "id": "prop-op-001", + "local_part": "+comments", + "profile": "p1", + "valid": false, + "error": "empty-target" + }, + { + "id": "prop-op-002", + "local_part": "!callback", + "profile": "p2", + "valid": false, + "error": "empty-target" + }, + { + "id": "prop-op-003", + "local_part": "ticket=PR42+", + "profile": "p1", + "valid": false, + "error": "empty-facet" + }, + { + "id": "prop-op-004", + "local_part": "ticket=+x", + "profile": "p1", + "valid": false, + "error": "empty-resource-id" + }, + { + "id": "prop-op-005", + "local_part": "tic.ket=PR42", + "profile": "p1", + "valid": false, + "error": "invalid-resource-type", + "note": "resource-type has no '.'" + }, + { + "id": "prop-op-006", + "local_part": "ticket=PR..42", + "profile": "p1", + "valid": false, + "error": "invalid-resource-id" + }, + { + "id": "prop-op-007", + "local_part": "ticket+.x", + "profile": "p1", + "valid": false, + "error": "invalid-facet" + }, + { + "id": "prop-op-008", + "local_part": "ticket!a..b", + "profile": "p2", + "valid": false, + "error": "invalid-interaction" + }, + { + "id": "prop-op-009", + "local_part": "a b", + "profile": "p1", + "valid": false, + "error": "invalid-target" + }, + { + "id": "prop-op-010", + "local_part": "ticket+a!", + "profile": "p2", + "valid": false, + "error": "empty-interaction" + }, + { + "id": "prop-kind-001", + "local_part": "research-companion", + "profile": "p1", + "valid": true, + "parsed": { + "target": "research-companion", + "target_kind": "opaque", + "facet": null, + "interaction": null + }, + "note": "no actor inference from '-'" + }, + { + "id": "prop-kind-002", + "local_part": "invoice_ingest", + "profile": "p1", + "valid": true, + "parsed": { + "target": "invoice_ingest", + "target_kind": "opaque", + "facet": null, + "interaction": null + }, + "note": "no function inference from '_'" + }, + { + "id": "prop-case-001", + "local_part": "Ticket=PR42", + "profile": "p1", + "valid": true, + "parsed": { + "target": "Ticket=PR42", + "target_kind": "resource", + "facet": null, + "interaction": null, + "resource_type": "Ticket", + "resource_id": "PR42" + }, + "note": "grammar permits upper-case resource types; statutes register lower-case" + } +] diff --git a/mas_conformance/runner.py b/mas_conformance/runner.py new file mode 100644 index 0000000..d7bdfff --- /dev/null +++ b/mas_conformance/runner.py @@ -0,0 +1,50 @@ +"""Run the conformance corpus against mas_core. Used by `mas test` and pytest.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from mas_core.parser import MasSyntaxError, parse_for_profile + +from . import address_fixtures, property_fixtures + + +@dataclass +class Report: + passed: int = 0 + failures: list = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.failures + + +def check_address_fixture(f: dict): + """Return None on pass, else a failure description.""" + try: + got = parse_for_profile(f["local_part"], f["profile"]) + except MasSyntaxError as e: + if f["valid"]: + return f"unexpected invalid ({e.code}: {e})" + if "error" in f and f["error"] != e.code and f["id"].startswith("prop-"): + return f"wrong error code: expected {f['error']}, got {e.code}" + return None + if not f["valid"]: + return f"expected invalid, parsed as {got.to_fixture_dict()}" + if got.to_fixture_dict() != f["parsed"]: + return f"parse mismatch: got {got.to_fixture_dict()}, expected {f['parsed']}" + for key in ("required_profile", "generator_allowed", "base"): + if key in f and got.to_dict()[key] != f[key]: + return f"{key} mismatch: got {got.to_dict()[key]!r}, expected {f[key]!r}" + return None + + +def run_parser_corpus() -> Report: + report = Report() + for f in address_fixtures() + property_fixtures(): + problem = check_address_fixture(f) + if problem: + report.failures.append((f["id"], f["local_part"], problem)) + else: + report.passed += 1 + return report diff --git a/mas_conformance/statutes/cases.json b/mas_conformance/statutes/cases.json new file mode 100644 index 0000000..7b639f1 --- /dev/null +++ b/mas_conformance/statutes/cases.json @@ -0,0 +1,122 @@ +[ + { + "id": "st-001", + "file": "valid-example.json", + "host": "coulomb.social", + "expect": "valid" + }, + { + "id": "st-002", + "file": "valid-great-service.json", + "host": "great-service.example", + "expect": "valid" + }, + { + "id": "st-003", + "file": "valid-great-service.json", + "host": "GREAT-SERVICE.example.", + "expect": "valid", + "note": "host canonicalization" + }, + { + "id": "st-004", + "file": "valid-example.json", + "host": "acme.coulomb.social", + "expect": "semantic", + "codes": [ + "namespace-mismatch" + ], + "note": "no parent-domain authority" + }, + { + "id": "st-010", + "file": "semantic-p2-disabled-modes.json", + "host": "great-service.example", + "expect": "semantic", + "codes": [ + "p2-disabled-modes-enabled" + ] + }, + { + "id": "st-011", + "file": "semantic-registry-facet.json", + "host": "great-service.example", + "expect": "schema", + "note": "the schema propertyNames pattern already excludes + ! $ ^" + }, + { + "id": "st-012", + "file": "semantic-registry-resource.json", + "host": "great-service.example", + "expect": "semantic", + "codes": [ + "registry-key-resource" + ] + }, + { + "id": "st-013", + "file": "semantic-registry-syntax.json", + "host": "great-service.example", + "expect": "semantic", + "codes": [ + "registry-key-syntax" + ] + }, + { + "id": "st-014", + "file": "semantic-registry-collision.json", + "host": "great-service.example", + "expect": "semantic", + "codes": [ + "registry-key-collision" + ] + }, + { + "id": "st-015", + "file": "semantic-inherits-self.json", + "host": "great-service.example", + "expect": "semantic", + "codes": [ + "inheritance-cycle" + ] + }, + { + "id": "st-020", + "file": "schema-resource-type-case.json", + "host": "great-service.example", + "expect": "schema" + }, + { + "id": "st-021", + "file": "schema-version.json", + "host": "great-service.example", + "expect": "schema" + }, + { + "id": "st-022", + "file": "schema-missing-equivalence.json", + "host": "great-service.example", + "expect": "schema" + }, + { + "id": "st-023", + "file": "schema-p3.json", + "host": "great-service.example", + "expect": "schema" + }, + { + "id": "st-024", + "file": "schema-inherits-http.json", + "host": "great-service.example", + "expect": "schema" + }, + { + "id": "st-030", + "file": "warning-sequential.json", + "host": "great-service.example", + "expect": "valid", + "warnings": [ + "enumerable-identifiers" + ] + } +] diff --git a/mas_conformance/statutes/schema-inherits-http.json b/mas_conformance/statutes/schema-inherits-http.json new file mode 100644 index 0000000..b1c60af --- /dev/null +++ b/mas_conformance/statutes/schema-inherits-http.json @@ -0,0 +1,31 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + }, + "inherits": "http://parent.example/.well-known/mas" +} diff --git a/mas_conformance/statutes/schema-missing-equivalence.json b/mas_conformance/statutes/schema-missing-equivalence.json new file mode 100644 index 0000000..7b65f90 --- /dev/null +++ b/mas_conformance/statutes/schema-missing-equivalence.json @@ -0,0 +1,26 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "addresses": { + "support": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/schema-p3.json b/mas_conformance/statutes/schema-p3.json new file mode 100644 index 0000000..aad21e0 --- /dev/null +++ b/mas_conformance/statutes/schema-p3.json @@ -0,0 +1,31 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true, + "p3": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/schema-resource-type-case.json b/mas_conformance/statutes/schema-resource-type-case.json new file mode 100644 index 0000000..4e624c3 --- /dev/null +++ b/mas_conformance/statutes/schema-resource-type-case.json @@ -0,0 +1,33 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + }, + "Ticket": { + "enabled": true + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/schema-version.json b/mas_conformance/statutes/schema-version.json new file mode 100644 index 0000000..3f8e663 --- /dev/null +++ b/mas_conformance/statutes/schema-version.json @@ -0,0 +1,30 @@ +{ + "mas_version": "0.2", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/semantic-inherits-self.json b/mas_conformance/statutes/semantic-inherits-self.json new file mode 100644 index 0000000..e13ea22 --- /dev/null +++ b/mas_conformance/statutes/semantic-inherits-self.json @@ -0,0 +1,31 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + }, + "inherits": "https://great-service.example/.well-known/mas" +} diff --git a/mas_conformance/statutes/semantic-p2-disabled-modes.json b/mas_conformance/statutes/semantic-p2-disabled-modes.json new file mode 100644 index 0000000..5c1b79e --- /dev/null +++ b/mas_conformance/statutes/semantic-p2-disabled-modes.json @@ -0,0 +1,30 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": false + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/semantic-registry-collision.json b/mas_conformance/statutes/semantic-registry-collision.json new file mode 100644 index 0000000..2be455b --- /dev/null +++ b/mas_conformance/statutes/semantic-registry-collision.json @@ -0,0 +1,33 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "ascii-insensitive", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + }, + "Support": { + "kind": "group" + } + } +} diff --git a/mas_conformance/statutes/semantic-registry-facet.json b/mas_conformance/statutes/semantic-registry-facet.json new file mode 100644 index 0000000..05e8adf --- /dev/null +++ b/mas_conformance/statutes/semantic-registry-facet.json @@ -0,0 +1,33 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + }, + "support+urgent": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/semantic-registry-resource.json b/mas_conformance/statutes/semantic-registry-resource.json new file mode 100644 index 0000000..7214b01 --- /dev/null +++ b/mas_conformance/statutes/semantic-registry-resource.json @@ -0,0 +1,33 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket=GS-1": { + "kind": "system" + } + } +} diff --git a/mas_conformance/statutes/semantic-registry-syntax.json b/mas_conformance/statutes/semantic-registry-syntax.json new file mode 100644 index 0000000..699af8c --- /dev/null +++ b/mas_conformance/statutes/semantic-registry-syntax.json @@ -0,0 +1,33 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + }, + ".support": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/valid-example.json b/mas_conformance/statutes/valid-example.json new file mode 100644 index 0000000..a0adc21 --- /dev/null +++ b/mas_conformance/statutes/valid-example.json @@ -0,0 +1,54 @@ +{ + "mas_version": "0.1", + "namespace": "coulomb.social", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service or work ticket", + "identifier_policy": "non-guessable-preferred" + }, + "invoice": { + "enabled": true, + "description": "Invoice communication resource", + "identifier_policy": "opaque" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + }, + "web": { + "enabled": true, + "description": "Continue in a web interface" + }, + "chat": { + "enabled": true, + "description": "Continue in a chat interface" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "unicode_local_part": false, + "addresses": { + "support": { + "kind": "function", + "description": "General support intake" + }, + "bernd.worsch": { + "kind": "person" + }, + "research-companion": { + "kind": "actor" + }, + "invoice_ingest": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/valid-great-service.json b/mas_conformance/statutes/valid-great-service.json new file mode 100644 index 0000000..755f429 --- /dev/null +++ b/mas_conformance/statutes/valid-great-service.json @@ -0,0 +1,30 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + } +} diff --git a/mas_conformance/statutes/warning-sequential.json b/mas_conformance/statutes/warning-sequential.json new file mode 100644 index 0000000..792c81a --- /dev/null +++ b/mas_conformance/statutes/warning-sequential.json @@ -0,0 +1,34 @@ +{ + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + }, + "build": { + "enabled": true, + "identifier_policy": "sequential-allowed" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + } + } +} diff --git a/mas_core/__init__.py b/mas_core/__init__.py new file mode 100644 index 0000000..cb69b73 --- /dev/null +++ b/mas_core/__init__.py @@ -0,0 +1,8 @@ +"""Mail Address Semantics (MAS) v0.1 reference implementation. + +Parsing is local and deterministic. Discovery is networked. Policy is pure. +Delivery remains external. +""" + +MAS_VERSION = "0.1" +__version__ = "0.1.0.dev0" diff --git a/mas_core/discovery.py b/mas_core/discovery.py new file mode 100644 index 0000000..32b99a5 --- /dev/null +++ b/mas_core/discovery.py @@ -0,0 +1,256 @@ +"""Exact-host MAS statute discovery. + +`discover(host)` fetches https://HOST/.well-known/mas and returns either a +`Discovered` (validated statute) or a typed `DiscoveryFailure`. It never +walks parent domains. Redirects are followed only within the same origin +(scheme, host, port); cross-host redirects and scheme downgrades fail. + +Discovery failure never invalidates an email address; it only means MAS +namespace semantics are unconfirmed. +""" + +from __future__ import annotations + +import http.client +import json +import ssl +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, Optional, Union +from urllib.parse import urljoin, urlsplit + +from .statute import ( + InheritanceCycle, + Statute, + StatuteInvalid, + build_statute, + canonical_host, + resolve_inheritance, +) + +WELL_KNOWN_PATH = "/.well-known/mas" +MAX_BYTES = 256 * 1024 +MAX_REDIRECTS = 5 + + +class FailureKind(str, Enum): + InvalidHost = "InvalidHost" + NotFound = "NotFound" + HttpFailure = "HttpFailure" + TransportFailure = "TransportFailure" + InvalidContentType = "InvalidContentType" + CrossHostRedirect = "CrossHostRedirect" + SchemeDowngrade = "SchemeDowngrade" + TooManyRedirects = "TooManyRedirects" + NotJson = "NotJson" + UnsupportedVersion = "UnsupportedVersion" + SchemaInvalid = "SchemaInvalid" + SemanticInvalid = "SemanticInvalid" + NamespaceMismatch = "NamespaceMismatch" + InheritanceCycle = "InheritanceCycle" + InheritanceFailure = "InheritanceFailure" + + +@dataclass(frozen=True) +class DiscoveryFailure: + host: str + url: str + kind: FailureKind + detail: str + cached: bool = False + ok = False + + def to_dict(self) -> dict: + return {"ok": False, "host": self.host, "url": self.url, "failure": self.kind.value, + "detail": self.detail, "cached": self.cached} + + +@dataclass(frozen=True) +class Discovered: + host: str + url: str + statute: Statute + cached: bool = False + ok = True + + def to_dict(self) -> dict: + return {"ok": True, "host": self.host, "url": self.url, "cached": self.cached, + "statute": self.statute.to_dict()} + + +DiscoveryResult = Union[Discovered, DiscoveryFailure] + + +@dataclass(frozen=True) +class Response: + status: int + headers: dict # lower-cased header names + body: bytes + + +Transport = Callable[[str, float], Response] + + +def http_transport(url: str, timeout: float) -> Response: + """One GET, no redirect following, bounded body. Raises OSError on failure.""" + parts = urlsplit(url) + if parts.scheme == "https": + conn = http.client.HTTPSConnection(parts.hostname, parts.port, timeout=timeout, + context=ssl.create_default_context()) + else: + conn = http.client.HTTPConnection(parts.hostname, parts.port, timeout=timeout) + try: + conn.request("GET", parts.path or "/", headers={"Accept": "application/json", + "User-Agent": "mas-core/0.1"}) + resp = conn.getresponse() + body = resp.read(MAX_BYTES + 1) + return Response(resp.status, {k.lower(): v for k, v in resp.getheaders()}, body) + finally: + conn.close() + + +class _Fail(Exception): + def __init__(self, kind: FailureKind, detail: str, url: str): + super().__init__(detail) + self.kind, self.detail, self.url = kind, detail, url + + +def _origin(url: str): + p = urlsplit(url) + default = {"https": 443, "http": 80}.get(p.scheme) + return (p.scheme, (p.hostname or "").lower(), p.port or default) + + +def _fetch_json(url: str, transport: Transport, timeout: float) -> tuple[str, object]: + """GET with same-origin redirects only; returns (final_url, decoded JSON).""" + start = _origin(url) + for _ in range(MAX_REDIRECTS + 1): + try: + resp = transport(url, timeout) + except (OSError, http.client.HTTPException) as e: + raise _Fail(FailureKind.TransportFailure, f"{type(e).__name__}: {e}", url) + if resp.status in (301, 302, 303, 307, 308): + loc = resp.headers.get("location") + if not loc: + raise _Fail(FailureKind.HttpFailure, f"{resp.status} without Location", url) + nxt = urljoin(url, loc) + o = _origin(nxt) + if o[0] != start[0]: + kind = FailureKind.SchemeDowngrade if o[0] == "http" else FailureKind.CrossHostRedirect + raise _Fail(kind, f"redirect to {nxt} changes scheme", url) + if o != start: + raise _Fail(FailureKind.CrossHostRedirect, + f"redirect to {nxt} would transfer MAS authority", url) + url = nxt + continue + if resp.status == 404 or resp.status == 410: + raise _Fail(FailureKind.NotFound, f"HTTP {resp.status}", url) + if not 200 <= resp.status < 300: + raise _Fail(FailureKind.HttpFailure, f"HTTP {resp.status}", url) + ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower() + if not (ctype == "application/json" or (ctype.startswith("application/") and ctype.endswith("+json"))): + raise _Fail(FailureKind.InvalidContentType, f"content-type {ctype or '(none)'!r}", url) + if len(resp.body) > MAX_BYTES: + raise _Fail(FailureKind.HttpFailure, f"statute exceeds {MAX_BYTES} bytes", url) + try: + return url, json.loads(resp.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise _Fail(FailureKind.NotJson, str(e), url) + raise _Fail(FailureKind.TooManyRedirects, f"more than {MAX_REDIRECTS} redirects", url) + + +def _validate(doc, host: str, url: str) -> Statute: + if isinstance(doc, dict) and "mas_version" in doc and doc["mas_version"] != "0.1": + raise _Fail(FailureKind.UnsupportedVersion, f"mas_version {doc['mas_version']!r}", url) + try: + return build_statute(doc, host, statute_url=url) + except StatuteInvalid as e: + if "namespace-mismatch" in e.codes: + raise _Fail(FailureKind.NamespaceMismatch, str(e), url) + if "inheritance-cycle" in e.codes: + raise _Fail(FailureKind.InheritanceCycle, str(e), url) + kind = FailureKind.SchemaInvalid if e.layer == "schema" else FailureKind.SemanticInvalid + raise _Fail(kind, str(e), url) + + +def statute_url(host: str, *, scheme: str = "https", port: Optional[int] = None) -> str: + netloc = host if port is None else f"{host}:{port}" + return f"{scheme}://{netloc}{WELL_KNOWN_PATH}" + + +@dataclass +class DiscoveryCache: + """Minimal cache. Successes and failures are stored and expire separately; + a cached failure is returned as the same typed failure, marked cached.""" + + success_ttl: float = 3600.0 + failure_ttl: float = 60.0 + clock: Callable[[], float] = time.monotonic + _ok: dict = field(default_factory=dict) + _fail: dict = field(default_factory=dict) + + def get(self, key) -> Optional[DiscoveryResult]: + for store in (self._ok, self._fail): + hit = store.get(key) + if hit and hit[0] > self.clock(): + r = hit[1] + return type(r)(**{**r.__dict__, "cached": True}) + store.pop(key, None) + return None + + def put(self, key, result: DiscoveryResult) -> None: + if result.ok: + self._fail.pop(key, None) + self._ok[key] = (self.clock() + self.success_ttl, result) + else: + self._fail[key] = (self.clock() + self.failure_ttl, result) + + +def discover(host: str, *, transport: Transport = http_transport, timeout: float = 5.0, + scheme: str = "https", port: Optional[int] = None, + resolve_inherits: bool = True, + cache: Optional[DiscoveryCache] = None) -> DiscoveryResult: + """Discover the statute for exactly `host`. Never consults parent domains. + + `scheme="http"` and `port` exist only for local fixture servers in tests; + production discovery is HTTPS on the default port. + """ + if scheme not in ("https", "http"): + raise ValueError("scheme must be https (or http for local test fixtures)") + raw_host = host + host = canonical_host(host) + url = statute_url(host, scheme=scheme, port=port) + if not host or any(c in host for c in "/@:?# ") : + return DiscoveryFailure(raw_host, url, FailureKind.InvalidHost, "not a hostname") + key = (scheme, host, port) + if cache is not None: + hit = cache.get(key) + if hit is not None: + return hit + result = _discover_uncached(host, url, transport, timeout, resolve_inherits) + if cache is not None: + cache.put(key, result) + return result + + +def _discover_uncached(host, url, transport, timeout, resolve_inherits) -> DiscoveryResult: + try: + final_url, doc = _fetch_json(url, transport, timeout) + statute = _validate(doc, host, url) + if statute.inherits and resolve_inherits: + def fetch_parent(purl: str) -> Statute: + phost = urlsplit(purl).hostname or "" + try: + _, pdoc = _fetch_json(purl, transport, timeout) + return _validate(pdoc, phost, purl) + except _Fail as e: + raise _Fail(FailureKind.InheritanceFailure, + f"parent {purl}: {e.kind.value}: {e.detail}", url) + try: + statute = resolve_inheritance(statute, url, fetch_parent) + except InheritanceCycle as e: + raise _Fail(FailureKind.InheritanceCycle, str(e), url) + return Discovered(host, url, statute) + except _Fail as e: + return DiscoveryFailure(host, e.url, e.kind, e.detail) diff --git a/mas_core/generator.py b/mas_core/generator.py new file mode 100644 index 0000000..2d34687 --- /dev/null +++ b/mas_core/generator.py @@ -0,0 +1,38 @@ +"""Canonical MAS v0.1 local-part generator. + +Generators are stricter than parsers: they never emit both a facet and an +interaction, and they enforce the 64-octet SMTP limit after encoding. +""" + +from __future__ import annotations + +from typing import Optional + +from .parser import MasSyntaxError, parse_local_part + + +def generate_local_part(target: str, *, facet: Optional[str] = None, + interaction: Optional[str] = None) -> str: + if facet is not None and interaction is not None: + raise MasSyntaxError("generator-facet-and-interaction", + "v0.1 generators MUST NOT emit both '+' and '!'") + s = target + if facet is not None: + s += "+" + facet + if interaction is not None: + s += "!" + interaction + parsed = parse_local_part(s) # enforces grammar, emptiness, reserved ops, length + if str(parsed.target) != target or (parsed.facet and parsed.facet.value) != facet \ + or (parsed.interaction and parsed.interaction.mode) != interaction: + raise MasSyntaxError("generator-component", "a component contains a MAS operator") + return s + + +def resource_local_part(resource_type: str, resource_id: str, *, facet: Optional[str] = None, + interaction: Optional[str] = None) -> str: + if not resource_type or not resource_id: + raise MasSyntaxError("generator-empty", "resource type and id must be non-empty") + if "=" in resource_type: + raise MasSyntaxError("invalid-resource-type", "resource type must not contain '='") + return generate_local_part(f"{resource_type}={resource_id}", facet=facet, + interaction=interaction) diff --git a/mas_core/model.py b/mas_core/model.py new file mode 100644 index 0000000..d8c1faa --- /dev/null +++ b/mas_core/model.py @@ -0,0 +1,131 @@ +"""Immutable syntax model for MAS v0.1 addresses. + +These objects carry syntactic facts only. Nothing here encodes a semantic +kind (person, actor, function): that requires statute or registry metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Union + + +class RequiredProfile(str, Enum): + """The minimum MAS profile whose grammar the syntax requires.""" + + P1 = "P1" + P2 = "P2" + + +@dataclass(frozen=True) +class OpaqueTarget: + value: str + + kind = "opaque" + + def __str__(self) -> str: + return self.value + + +@dataclass(frozen=True) +class ResourceTarget: + resource_type: str + resource_id: str + + kind = "resource" + + def __str__(self) -> str: + return f"{self.resource_type}={self.resource_id}" + + +Target = Union[OpaqueTarget, ResourceTarget] + + +@dataclass(frozen=True) +class Facet: + value: str + + def __str__(self) -> str: + return self.value + + +@dataclass(frozen=True) +class Interaction: + mode: str + + def __str__(self) -> str: + return self.mode + + +@dataclass(frozen=True) +class ParsedLocalPart: + """A canonical MAS v0.1 local-part, parsed with the maximal (P2) grammar.""" + + raw: str + target: Target + facet: Optional[Facet] = None + interaction: Optional[Interaction] = None + + @property + def required_profile(self) -> RequiredProfile: + return RequiredProfile.P2 if self.interaction is not None else RequiredProfile.P1 + + @property + def base(self) -> str: + """The base target: what remains if a mail system strips the facet.""" + return str(self.target) + + @property + def generator_allowed(self) -> bool: + """v0.1 generators MUST NOT emit both a facet and an interaction.""" + return not (self.facet is not None and self.interaction is not None) + + def without_facet(self) -> "ParsedLocalPart": + return ParsedLocalPart(raw=_render(self.target, None, self.interaction), + target=self.target, interaction=self.interaction) + + def to_fixture_dict(self) -> dict: + """Shape used by the conformance corpus (`parsed` objects).""" + out = { + "target": str(self.target), + "target_kind": self.target.kind, + "facet": self.facet.value if self.facet else None, + "interaction": self.interaction.mode if self.interaction else None, + } + if isinstance(self.target, ResourceTarget): + out["resource_type"] = self.target.resource_type + out["resource_id"] = self.target.resource_id + return out + + def to_dict(self) -> dict: + out = self.to_fixture_dict() + out["required_profile"] = self.required_profile.value + out["base"] = self.base + out["generator_allowed"] = self.generator_allowed + return out + + +@dataclass(frozen=True) +class ParsedAddress: + local: ParsedLocalPart + domain: str + + @property + def required_profile(self) -> RequiredProfile: + return self.local.required_profile + + def __str__(self) -> str: + return f"{self.local.raw}@{self.domain}" + + def to_dict(self) -> dict: + return {"address": str(self), "domain": self.domain, **self.local.to_dict()} + + +def _render(target: Target, facet: Optional[Facet], interaction: Optional[Interaction]) -> str: + s = str(target) + if facet is not None: + s += "+" + facet.value + if interaction is not None: + s += "!" + interaction.mode + return s diff --git a/mas_core/parser.py b/mas_core/parser.py new file mode 100644 index 0000000..a19f902 --- /dev/null +++ b/mas_core/parser.py @@ -0,0 +1,167 @@ +"""Canonical MAS v0.1 local-part parser. + +The parser is local and deterministic: it performs no I/O, consults no +statute, and never infers a semantic kind from ".", "-" or "_". + +It always parses with the maximal (P2) grammar and records the profile the +syntax requires. Whether a namespace enables that profile is decided later, +by policy. `validate_profile` checks a parse against a parser conformance +class (P1 or P2) without re-parsing. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from .model import ( + Facet, + Interaction, + OpaqueTarget, + ParsedAddress, + ParsedLocalPart, + RequiredProfile, + ResourceTarget, +) + +LOCAL_PART_MAX_OCTETS = 64 + +_ALNUM = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") +ATOM_CHARS = _ALNUM | frozenset("#%&'*-/?_`{|}~") +RESOURCE_TYPE_CHARS = _ALNUM | frozenset("-_") +VALUE_CHARS = ATOM_CHARS | frozenset("=") # resource-id-char and value-char +RESERVED_OPERATORS = frozenset("$^") + + +class MasSyntaxError(ValueError): + """A local-part is not canonical MAS v0.1 syntax. + + `code` is a stable machine-readable identifier; the message is for humans. + """ + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +def _dot_string(s: str, allowed: frozenset) -> bool: + if not s or s.startswith(".") or s.endswith(".") or ".." in s: + return False + return all(c == "." or c in allowed for c in s) + + +def parse_local_part(value: str) -> ParsedLocalPart: + """Parse a canonical MAS v0.1 local-part with the maximal P2 grammar.""" + if not isinstance(value, str) or not value: + raise MasSyntaxError("empty", "empty local-part") + try: + encoded = value.encode("ascii") + except UnicodeEncodeError: + raise MasSyntaxError("non-ascii", "non-ASCII local-parts are outside canonical MAS v0.1") + if '"' in value or "\\" in value: + raise MasSyntaxError("quoted", "quoted or escaped local-parts are outside canonical MAS v0.1") + if "@" in value: + raise MasSyntaxError("at-sign", "'@' is not allowed in a local-part") + if any(c in RESERVED_OPERATORS for c in value): + raise MasSyntaxError("reserved-operator", "'$' and '^' are reserved in MAS v0.1") + if value.count("+") > 1: + raise MasSyntaxError("multiple-facets", "at most one '+' facet operator is allowed") + if value.count("!") > 1: + raise MasSyntaxError("multiple-interactions", "at most one '!' interaction operator is allowed") + + plus, bang = value.find("+"), value.find("!") + if plus >= 0 and bang >= 0 and bang < plus: + raise MasSyntaxError("operator-order", "canonical order is TARGET[+FACET][!INTERACTION]") + + rest = value + interaction: Optional[Interaction] = None + if bang >= 0: + rest, mode = rest[:bang], rest[bang + 1:] + if not mode: + raise MasSyntaxError("empty-interaction", "interaction mode is empty") + if not _dot_string(mode, VALUE_CHARS): + raise MasSyntaxError("invalid-interaction", f"invalid interaction mode {mode!r}") + interaction = Interaction(mode) + + facet: Optional[Facet] = None + if plus >= 0: + rest, fval = rest[:plus], rest[plus + 1:] + if not fval: + raise MasSyntaxError("empty-facet", "facet is empty") + if not _dot_string(fval, VALUE_CHARS): + raise MasSyntaxError("invalid-facet", f"invalid facet {fval!r}") + facet = Facet(fval) + + if not rest: + raise MasSyntaxError("empty-target", "target is empty") + + if "=" in rest: + # Only the first "=" is structural; later ones are resource-id data. + rtype, rid = rest.split("=", 1) + if not rtype: + raise MasSyntaxError("empty-resource-type", "resource type is empty") + if not rid: + raise MasSyntaxError("empty-resource-id", "resource id is empty") + if not all(c in RESOURCE_TYPE_CHARS for c in rtype): + raise MasSyntaxError("invalid-resource-type", f"invalid resource type {rtype!r}") + if not _dot_string(rid, VALUE_CHARS): + raise MasSyntaxError("invalid-resource-id", f"invalid resource id {rid!r}") + target = ResourceTarget(rtype, rid) + else: + if not _dot_string(rest, ATOM_CHARS): + raise MasSyntaxError("invalid-target", f"invalid opaque target {rest!r}") + target = OpaqueTarget(rest) + + if len(encoded) > LOCAL_PART_MAX_OCTETS: + raise MasSyntaxError("too-long", f"local-part is {len(encoded)} octets; SMTP limit is 64") + + return ParsedLocalPart(raw=value, target=target, facet=facet, interaction=interaction) + + +def split_address(value: str) -> tuple[str, str]: + """Split at the last '@'. The domain is lower-cased (DNS is case-insensitive).""" + if not isinstance(value, str) or "@" not in value: + raise MasSyntaxError("no-domain", "address has no '@domain'") + local, domain = value.rsplit("@", 1) + domain = domain.strip().rstrip(".").lower() + if not domain: + raise MasSyntaxError("no-domain", "address has an empty domain") + return local, domain + + +def parse_address(value: str) -> ParsedAddress: + local, domain = split_address(value) + return ParsedAddress(local=parse_local_part(local), domain=domain) + + +@dataclass(frozen=True) +class ValidationResult: + ok: bool + code: Optional[str] = None + message: Optional[str] = None + + def __bool__(self) -> bool: + return self.ok + + +def _profile(p) -> RequiredProfile: + return p if isinstance(p, RequiredProfile) else RequiredProfile(str(p).upper()) + + +def validate_profile(parsed, profile) -> ValidationResult: + """Check a parse against a parser conformance class (P1 or P2).""" + local = parsed.local if isinstance(parsed, ParsedAddress) else parsed + profile = _profile(profile) + if profile is RequiredProfile.P1 and local.required_profile is RequiredProfile.P2: + return ValidationResult(False, "profile-p2-required", + "'!' interaction requires P2; P1 rejects it as a MAS operator") + return ValidationResult(True) + + +def parse_for_profile(value: str, profile) -> ParsedLocalPart: + """Parse and enforce a parser conformance class; raises MasSyntaxError.""" + parsed = parse_local_part(value) + result = validate_profile(parsed, profile) + if not result: + raise MasSyntaxError(result.code, result.message) + return parsed diff --git a/mas_core/policy.py b/mas_core/policy.py new file mode 100644 index 0000000..a046b0f --- /dev/null +++ b/mas_core/policy.py @@ -0,0 +1,165 @@ +"""Pure MAS policy: parse tree + validated statute (+ local registry) -> semantics. + +No network access, no clock, no randomness. Without a statute, nothing +beyond syntax is claimed: opaque targets stay `kind = opaque`, resource +syntax stays unconfirmed, interaction preferences stay unconfirmed. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Mapping, Optional + +from .model import ParsedAddress, ParsedLocalPart, ResourceTarget +from .statute import Statute + +# Kind sources, in the order they are consulted. +SOURCE_STATUTE = "statute" +SOURCE_LOCAL = "local-registry" +SOURCE_NONE = "none" + + +@dataclass(frozen=True) +class SemanticFailure: + code: str # ProfileNotEnabled | InteractionModeNotEnabled | ResourceTypeNotEnabled | UnknownResourceType + message: str + + +@dataclass(frozen=True) +class AddressSemantics: + address: str + parsed: ParsedLocalPart + namespace_confirmed: bool + kind: str + kind_source: str + registry_key: Optional[str] = None + resource_type: Optional[str] = None + resource_id: Optional[str] = None + facet: Optional[str] = None + interaction: Optional[str] = None + equivalence_key: str = "" + failures: tuple[SemanticFailure, ...] = () + unknowns: tuple[str, ...] = () + + @property + def ok(self) -> bool: + return not self.failures + + def to_dict(self) -> dict: + return { + "address": self.address, + "namespace_confirmed": self.namespace_confirmed, + "kind": self.kind, + "kind_source": self.kind_source, + "registry_key": self.registry_key, + "resource_type": self.resource_type, + "resource_id": self.resource_id, + "facet": self.facet, + "interaction": self.interaction, + "required_profile": self.parsed.required_profile.value, + "equivalence_key": self.equivalence_key, + "failures": [{"code": f.code, "message": f.message} for f in self.failures], + "unknowns": list(self.unknowns), + } + + +def _fold(s: str, case: str, dots: bool) -> str: + if case == "ascii-insensitive": + s = s.lower() + if dots: + s = s.replace(".", "") + return s + + +def equivalence_key(parsed: ParsedLocalPart, statute: Optional[Statute]) -> str: + """Comparison key for the base target. Exact unless a statute declares folding. + + Dot folding applies to opaque targets only, never to resource identifiers. + """ + base = parsed.base + if statute is None: + return base + if isinstance(parsed.target, ResourceTarget): + return _fold(base, statute.local_part_case, False) + return _fold(base, statute.local_part_case, statute.dot_folding) + + +def _lookup(registry: Mapping[str, Mapping], key: str, case: str, dots: bool): + if key in registry: + return key, registry[key] + want = _fold(key, case, dots) + for k, v in registry.items(): + if _fold(k, case, dots) == want: + return k, v + return None, None + + +def resolve(parsed, statute: Optional[Statute] = None, + local_registry: Optional[Mapping] = None, *, domain: Optional[str] = None) -> AddressSemantics: + """Resolve semantics. `local_registry` has the statute's `addresses` / + `resource_types` shape and applies only where the statute is silent.""" + if isinstance(parsed, ParsedAddress): + domain, parsed = parsed.domain, parsed.local + address = f"{parsed.raw}@{domain}" if domain else parsed.raw + local = local_registry or {} + failures: list[SemanticFailure] = [] + unknowns: list[str] = [] + case = statute.local_part_case if statute else "exact" + dots = statute.dot_folding if statute else False + + kind, source, reg_key = "opaque", SOURCE_NONE, None + rtype = rid = None + target = parsed.target + + if statute is None: + unknowns.append("namespace: no validated MAS statute; semantics unconfirmed") + + if isinstance(target, ResourceTarget): + rtype, rid = target.resource_type, target.resource_id + if statute is not None and rtype in statute.resource_types: + if statute.resource_type_enabled(rtype): + kind, source = "resource", SOURCE_STATUTE + else: + failures.append(SemanticFailure("ResourceTypeNotEnabled", + f"resource type {rtype!r} is disabled by the statute")) + elif local.get("resource_types", {}).get(rtype, {}).get("enabled"): + kind, source = "resource", SOURCE_LOCAL + elif statute is not None: + failures.append(SemanticFailure("UnknownResourceType", + f"statute does not declare resource type {rtype!r}")) + else: + unknowns.append(f"resource type {rtype!r}: '=' syntax alone confers no application semantics") + else: + if statute is not None: + reg_key, entry = _lookup(statute.addresses, target.value, case, dots) + if entry: + kind, source = entry["kind"], SOURCE_STATUTE + if source == SOURCE_NONE: + k, entry = _lookup(local.get("addresses", {}), target.value, case, dots) + if entry: + reg_key, kind, source = k, entry["kind"], SOURCE_LOCAL + if source == SOURCE_NONE: + unknowns.append("kind: no registry entry; punctuation is not a classifier") + + facet = parsed.facet.value if parsed.facet else None + if facet is not None: + unknowns.append(f"facet {facet!r}: application-defined; may be stripped in transit") + + mode = parsed.interaction.mode if parsed.interaction else None + if mode is not None: + if statute is None: + unknowns.append(f"interaction {mode!r}: P2 support unconfirmed") + elif not statute.p2: + failures.append(SemanticFailure("ProfileNotEnabled", + "address uses '!' (P2) but the statute does not enable P2")) + elif not statute.interaction_mode_enabled(mode): + failures.append(SemanticFailure("InteractionModeNotEnabled", + f"interaction mode {mode!r} is not enabled by the statute")) + + return AddressSemantics( + address=address, parsed=parsed, namespace_confirmed=statute is not None, + kind=kind, kind_source=source, registry_key=reg_key, + resource_type=rtype, resource_id=rid, facet=facet, interaction=mode, + equivalence_key=equivalence_key(parsed, statute), + failures=tuple(failures), unknowns=tuple(unknowns), + ) diff --git a/mas_core/schema/mas-statute-v0.1.schema.json b/mas_core/schema/mas-statute-v0.1.schema.json new file mode 100644 index 0000000..68dc104 --- /dev/null +++ b/mas_core/schema/mas-statute-v0.1.schema.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.invalid/mas/schema/mas-statute-v0.1.schema.json", + "title": "Mail Address Semantics v0.1 Statute", + "type": "object", + "additionalProperties": false, + "required": [ + "mas_version", + "namespace", + "profiles", + "equivalence" + ], + "properties": { + "mas_version": { + "const": "0.1" + }, + "namespace": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "description": "Exact host governed by this statute, in its canonical DNS/IDNA presentation form." + }, + "inherits": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "description": "Optional explicit defaults-only parent statute URI. No DNS parent walking is implied." + }, + "profiles": { + "type": "object", + "additionalProperties": false, + "required": [ + "p1", + "p2" + ], + "properties": { + "p1": { + "const": true + }, + "p2": { + "type": "boolean" + } + } + }, + "resource_types": { + "type": "object", + "default": {}, + "propertyNames": { + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "identifier_policy": { + "type": "string", + "enum": [ + "opaque", + "non-guessable-preferred", + "sequential-allowed" + ] + } + } + } + }, + "interaction_modes": { + "type": "object", + "default": {}, + "propertyNames": { + "pattern": "^[A-Za-z0-9_-]+$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "description": { + "type": "string", + "maxLength": 500 + } + } + } + }, + "equivalence": { + "type": "object", + "additionalProperties": false, + "required": [ + "local_part_case", + "dot_folding" + ], + "properties": { + "local_part_case": { + "type": "string", + "enum": [ + "exact", + "ascii-insensitive" + ] + }, + "dot_folding": { + "type": "boolean" + } + } + }, + "unicode_local_part": { + "type": "boolean", + "default": false, + "description": "Experimental/private extension only; not canonical v0.1 machine syntax." + }, + "addresses": { + "type": "object", + "default": {}, + "propertyNames": { + "pattern": "^[A-Za-z0-9#%&'*\\-/=?_`{|}~.]+$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "person", + "actor", + "function", + "group", + "system", + "opaque" + ] + }, + "description": { + "type": "string", + "maxLength": 500 + } + } + } + } + } +} diff --git a/mas_core/statute.py b/mas_core/statute.py new file mode 100644 index 0000000..0d494ec --- /dev/null +++ b/mas_core/statute.py @@ -0,0 +1,258 @@ +"""MAS v0.1 statute model, schema validation, semantic validation, inheritance. + +Everything here is pure: documents come in as already-decoded JSON objects, +and inheritance resolution takes a caller-supplied `fetch` function. Network +access lives in `mas_core.discovery` only. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from importlib import resources +from types import MappingProxyType +from typing import Callable, Mapping, Optional + +from jsonschema import Draft202012Validator, FormatChecker + +from .parser import ATOM_CHARS, RESOURCE_TYPE_CHARS, MasSyntaxError, parse_local_part + +ERROR, WARNING = "error", "warning" +SCHEMA, SEMANTIC = "schema", "semantic" + + +@dataclass(frozen=True) +class Finding: + layer: str # "schema" | "semantic" + severity: str # "error" | "warning" + code: str + path: str + message: str + + def __str__(self) -> str: + return f"{self.layer} {self.severity} [{self.code}] {self.path or '/'}: {self.message}" + + +class StatuteInvalid(ValueError): + def __init__(self, findings: list[Finding]): + self.findings = [f for f in findings if f.severity == ERROR] + super().__init__("; ".join(str(f) for f in self.findings)) + + @property + def layer(self) -> str: + return SCHEMA if any(f.layer == SCHEMA for f in self.findings) else SEMANTIC + + @property + def codes(self) -> set[str]: + return {f.code for f in self.findings} + + +@dataclass(frozen=True) +class Statute: + namespace: str + p2: bool + resource_types: Mapping[str, Mapping] + interaction_modes: Mapping[str, Mapping] + local_part_case: str + dot_folding: bool + addresses: Mapping[str, Mapping] + inherits: Optional[str] = None + unicode_local_part: bool = False + inherited_from: tuple[str, ...] = () + document: Mapping = field(default_factory=dict, compare=False, repr=False) + + def resource_type_enabled(self, name: str) -> bool: + spec = self.resource_types.get(name) + return bool(spec and spec.get("enabled")) + + def interaction_mode_enabled(self, name: str) -> bool: + spec = self.interaction_modes.get(name) + return bool(spec and spec.get("enabled")) + + def to_dict(self) -> dict: + return { + "namespace": self.namespace, + "profiles": {"p1": True, "p2": self.p2}, + "resource_types": {k: dict(v) for k, v in self.resource_types.items()}, + "interaction_modes": {k: dict(v) for k, v in self.interaction_modes.items()}, + "equivalence": {"local_part_case": self.local_part_case, "dot_folding": self.dot_folding}, + "addresses": {k: dict(v) for k, v in self.addresses.items()}, + "inherits": self.inherits, + "inherited_from": list(self.inherited_from), + } + + +_SCHEMA = json.loads(resources.files("mas_core").joinpath( + "schema/mas-statute-v0.1.schema.json").read_text(encoding="utf-8")) +_VALIDATOR = Draft202012Validator(_SCHEMA, format_checker=FormatChecker()) + + +def canonical_host(host: str) -> str: + """Normal domain-name canonicalization: case, trailing dot, IDNA A-label.""" + h = host.strip().rstrip(".").lower() + try: + return h.encode("idna").decode("ascii") + except UnicodeError: + return h + + +def schema_findings(doc) -> list[Finding]: + out = [] + for err in sorted(_VALIDATOR.iter_errors(doc), key=lambda e: list(e.absolute_path)): + path = "/" + "/".join(str(p) for p in err.absolute_path) + out.append(Finding(SCHEMA, ERROR, "schema-" + str(err.validator), path, err.message)) + return out + + +def _fold(key: str, case: str, dots: bool) -> str: + if case == "ascii-insensitive": + key = key.lower() + if dots: + key = key.replace(".", "") + return key + + +def semantic_findings(doc: Mapping, host: Optional[str] = None, + statute_url: Optional[str] = None) -> list[Finding]: + """Checks beyond the JSON schema. Assumes `doc` is schema-valid.""" + out: list[Finding] = [] + + def add(sev, code, path, msg): + out.append(Finding(SEMANTIC, sev, code, path, msg)) + + ns = doc["namespace"] + if canonical_host(ns) != ns: + add(WARNING, "namespace-not-canonical", "/namespace", + f"namespace should be in canonical form {canonical_host(ns)!r}") + if host is not None and canonical_host(ns) != canonical_host(host): + add(ERROR, "namespace-mismatch", "/namespace", + f"statute declares {ns!r} but was retrieved for host {host!r}") + + p2 = doc["profiles"]["p2"] + modes = doc.get("interaction_modes", {}) + enabled_modes = [m for m, spec in modes.items() if spec["enabled"]] + if not p2 and enabled_modes: + add(ERROR, "p2-disabled-modes-enabled", "/interaction_modes", + f"profiles.p2 is false but interaction modes are enabled: {', '.join(enabled_modes)}") + if p2 and not enabled_modes and not doc.get("inherits"): + add(WARNING, "p2-without-modes", "/profiles/p2", + "P2 is enabled but no interaction mode is enabled") + + for name, spec in doc.get("resource_types", {}).items(): + path = f"/resource_types/{name}" + if not name or not all(c in RESOURCE_TYPE_CHARS for c in name): + add(ERROR, "invalid-resource-type-name", path, "name violates MAS resource-type syntax") + if spec.get("identifier_policy") == "sequential-allowed": + add(WARNING, "enumerable-identifiers", path, + "sequential identifiers are enumerable; identifiers are not credentials") + + case = doc["equivalence"]["local_part_case"] + dots = doc["equivalence"]["dot_folding"] + seen: dict[str, str] = {} + for key in doc.get("addresses", {}): + path = f"/addresses/{key}" + bad = sorted(set(key) & set("+!$^")) + if bad: + add(ERROR, "registry-key-operator", path, + f"registry keys name base opaque targets; {' '.join(bad)} not allowed") + continue + try: + parsed = parse_local_part(key) + except MasSyntaxError as e: + add(ERROR, "registry-key-syntax", path, str(e)) + continue + if parsed.target.kind != "opaque": + add(ERROR, "registry-key-resource", path, + "registry keys must be opaque targets; resources are declared via resource_types") + continue + folded = _fold(key, case, dots) + if folded in seen: + add(ERROR, "registry-key-collision", path, + f"collides with {seen[folded]!r} under the declared equivalence policy") + seen[folded] = key + + inherits = doc.get("inherits") + own_url = statute_url or f"https://{canonical_host(ns)}/.well-known/mas" + if inherits and inherits.rstrip("/") == own_url.rstrip("/"): + add(ERROR, "inheritance-cycle", "/inherits", "statute inherits from itself") + + if doc.get("unicode_local_part"): + add(WARNING, "unicode-local-part", "/unicode_local_part", + "experimental: Unicode local-parts are not canonical MAS v0.1 syntax") + return out + + +def lint(doc, host: Optional[str] = None) -> list[Finding]: + """All findings: schema errors, or (if schema-valid) semantic errors and warnings.""" + found = schema_findings(doc) + if found: + return found + return semantic_findings(doc, host) + + +def build_statute(doc: Mapping, host: Optional[str] = None, + statute_url: Optional[str] = None) -> Statute: + """Validate (schema + semantics) and build a Statute. Raises StatuteInvalid.""" + found = schema_findings(doc) or semantic_findings(doc, host, statute_url) + if any(f.severity == ERROR for f in found): + raise StatuteInvalid(found) + ro = lambda d: MappingProxyType({k: MappingProxyType(dict(v)) for k, v in d.items()}) + return Statute( + namespace=canonical_host(doc["namespace"]), + p2=doc["profiles"]["p2"], + resource_types=ro(doc.get("resource_types", {})), + interaction_modes=ro(doc.get("interaction_modes", {})), + local_part_case=doc["equivalence"]["local_part_case"], + dot_folding=doc["equivalence"]["dot_folding"], + addresses=ro(doc.get("addresses", {})), + inherits=doc.get("inherits"), + unicode_local_part=doc.get("unicode_local_part", False), + document=MappingProxyType(dict(doc)), + ) + + +def merge_defaults(child: Statute, parent: Statute, parent_url: str) -> Statute: + """Defaults-only inheritance: child wins; address registry never inherits.""" + rt = {**parent.resource_types, **child.resource_types} + im = {**parent.interaction_modes, **child.interaction_modes} + return Statute( + namespace=child.namespace, p2=child.p2, + resource_types=MappingProxyType(rt), interaction_modes=MappingProxyType(im), + local_part_case=child.local_part_case, dot_folding=child.dot_folding, + addresses=child.addresses, inherits=child.inherits, + unicode_local_part=child.unicode_local_part, + inherited_from=(parent_url,) + parent.inherited_from, + document=child.document, + ) + + +class InheritanceCycle(ValueError): + def __init__(self, chain: list[str]): + super().__init__(" -> ".join(chain)) + self.chain = chain + + +def resolve_inheritance(statute: Statute, own_url: str, + fetch: Callable[[str], Statute], max_depth: int = 8) -> Statute: + """Follow explicit `inherits` URIs only, detecting cycles (A -> B -> A, ...). + + `fetch(url)` must return an independently validated parent Statute, or + raise. Parents are fetched for their own namespace; their registry is + discarded by `merge_defaults`. + """ + chain = [own_url.rstrip("/")] + statutes = [statute] + cur = statute + while cur.inherits: + url = cur.inherits.rstrip("/") + if url in chain: + raise InheritanceCycle(chain + [url]) + if len(chain) > max_depth: + raise InheritanceCycle(chain + [url + " (depth limit)"]) + chain.append(url) + cur = fetch(cur.inherits) + statutes.append(cur) + merged = statutes[-1] + for i in range(len(statutes) - 2, -1, -1): + merged = merge_defaults(statutes[i], merged, chain[i + 1]) + return merged diff --git a/mas_gateway/__init__.py b/mas_gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mas_gateway/core.py b/mas_gateway/core.py new file mode 100644 index 0000000..9d378a9 --- /dev/null +++ b/mas_gateway/core.py @@ -0,0 +1,135 @@ +"""Application-neutral MAS mail gateway core. + + email recipient -> MAS parse -> statute/policy resolution -> normalized event + +The gateway knows nothing about tickets or any other resource type. It +forwards every recipient, including ones that are not canonical MAS syntax +(MAS never makes ordinary email depend on MAS), and lets the application +decide what to do. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from email import policy +from email.message import EmailMessage +from email.parser import BytesParser +from email.utils import getaddresses +from pathlib import Path +from typing import Callable, Optional + +from mas_core.discovery import Discovered, DiscoveryCache, DiscoveryResult, discover +from mas_core.parser import MasSyntaxError, parse_address +from mas_core.policy import resolve +from mas_core.statute import build_statute + +EVENT_VERSION = "0.1" + + +def message_meta_from_rfc822(raw: bytes) -> dict: + msg: EmailMessage = BytesParser(policy=policy.default).parsebytes(raw) + body = msg.get_body(preferencelist=("plain",)) + text = body.get_content() if body is not None else "" + return { + "from": _first_addr(msg.get("From")), + "to": [a for _, a in getaddresses([str(v) for v in msg.get_all("To", [])])], + "subject": str(msg.get("Subject", "")), + "message_id": str(msg.get("Message-ID", "")) or None, + "in_reply_to": str(msg.get("In-Reply-To", "")) or None, + "references": str(msg.get("References", "")) or None, + "date": str(msg.get("Date", "")) or None, + "auto_submitted": str(msg.get("Auto-Submitted", "")) or None, + "precedence": str(msg.get("Precedence", "")) or None, + "text": text, + } + + +def _first_addr(value) -> Optional[str]: + if not value: + return None + pairs = getaddresses([str(value)]) + return pairs[0][1] if pairs else None + + +@dataclass +class Resolver: + """Discovery (cached) plus optional pinned statutes from local configuration.""" + + pinned: dict = field(default_factory=dict) # host -> Statute + cache: DiscoveryCache = field(default_factory=DiscoveryCache) + discover_fn: Callable[..., DiscoveryResult] = discover + local_registry: Optional[dict] = None + + @classmethod + def with_statute_files(cls, files, **kw) -> "Resolver": + pinned = {} + for f in files or (): + doc = json.loads(Path(f).read_text()) + st = build_statute(doc, doc.get("namespace")) + pinned[st.namespace] = st + return cls(pinned=pinned, **kw) + + def statute_for(self, host: str) -> DiscoveryResult: + if host in self.pinned: + return Discovered(host, "pinned:local-configuration", self.pinned[host]) + return self.discover_fn(host, cache=self.cache) + + +def build_event(recipient: str, message: dict, resolver: Resolver) -> dict: + try: + addr = parse_address(recipient) + except MasSyntaxError as e: + mas = {"valid": False, "error": e.code, "detail": str(e)} + return {"mas_event_version": EVENT_VERSION, "recipient": recipient, "mas": mas, "message": message} + + disc = resolver.statute_for(addr.domain) + statute = disc.statute if disc.ok else None + sem = resolve(addr, statute, resolver.local_registry) + mas = { + "valid": True, + "domain": addr.domain, + "local_part": addr.local.raw, + "base": addr.local.base, + "target": str(addr.local.target), + "target_syntax": addr.local.target.kind, + "discovery": {"ok": True, "url": disc.url} if disc.ok else + {"ok": False, "failure": disc.kind.value, "detail": disc.detail}, + **{k: v for k, v in sem.to_dict().items() if k != "address"}, + } + mas["profile"] = mas.pop("required_profile") + return {"mas_event_version": EVENT_VERSION, "recipient": recipient, "mas": mas, "message": message} + + +@dataclass +class Delivery: + recipient: str + status: Optional[int] + outcome: str # "delivered" | "rejected-by-app" | "tempfail" + response: Optional[object] = None + + +def post_event(event: dict, app_url: str, timeout: float = 10.0) -> Delivery: + req = urllib.request.Request(app_url, data=json.dumps(event).encode(), method="POST", + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return Delivery(event["recipient"], resp.status, "delivered", _json(resp.read())) + except urllib.error.HTTPError as e: + outcome = "tempfail" if e.code >= 500 else "rejected-by-app" + return Delivery(event["recipient"], e.code, outcome, _json(e.read())) + except (OSError, urllib.error.URLError) as e: + return Delivery(event["recipient"], None, "tempfail", str(e)) + + +def _json(b: bytes): + try: + return json.loads(b.decode() or "null") + except (UnicodeDecodeError, json.JSONDecodeError): + return b.decode(errors="replace") + + +def gateway(recipients: list[str], message: dict, resolver: Resolver, app_url: str) -> list[Delivery]: + return [post_event(build_event(r, message, resolver), app_url) for r in recipients] diff --git a/mas_gateway/main.py b/mas_gateway/main.py new file mode 100644 index 0000000..3074365 --- /dev/null +++ b/mas_gateway/main.py @@ -0,0 +1,114 @@ +"""`mas-gateway` — bridge inbound mail to an HTTP application as MAS events. + +Two inputs, one core: + + mas-gateway pipe --recipient R [--recipient R2] --app-url URL < message.eml + For an MTA pipe transport, e.g. Postfix master.cf: + mas unix - n n - - pipe + flags=Rq user=mas argv=/usr/local/bin/mas-gateway pipe + --app-url http://127.0.0.1:8025/mas-events --recipient ${recipient} + Exit 0 when the app accepted or deliberately declined the message (no + bounce, hence no backscatter); exit 75 (EX_TEMPFAIL) when the app is + unreachable or failed, so the MTA retries. + + mas-gateway webhook --listen 127.0.0.1:8026 --app-url URL + Provider-style inbound webhook: POST JSON + {"recipient" | "recipients", "from", "subject", "text", "message_id", "in_reply_to", ...} + or a raw message with Content-Type message/rfc822 and ?recipient=... . +""" + +from __future__ import annotations + +import argparse +import json +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +from .core import Resolver, gateway, message_meta_from_rfc822 + +EX_TEMPFAIL = 75 +MAX_BODY = 25 * 1024 * 1024 + + +def cmd_pipe(args, resolver) -> int: + raw = sys.stdin.buffer.read() + deliveries = gateway(args.recipient, message_meta_from_rfc822(raw), resolver, args.app_url) + for d in deliveries: + print(json.dumps(d.__dict__), file=sys.stderr) + return EX_TEMPFAIL if any(d.outcome == "tempfail" for d in deliveries) else 0 + + +def webhook_message(body: bytes, ctype: str, query: dict) -> tuple[list[str], dict]: + if ctype.startswith("message/rfc822"): + return query.get("recipient", []), message_meta_from_rfc822(body) + data = json.loads(body.decode()) + recipients = data.pop("recipients", None) or [data.pop("recipient")] + meta = {"from": data.get("from"), "to": data.get("to", recipients), + "subject": data.get("subject", ""), "message_id": data.get("message_id"), + "in_reply_to": data.get("in_reply_to"), "references": data.get("references"), + "date": data.get("date"), "auto_submitted": data.get("auto_submitted"), + "precedence": data.get("precedence"), "text": data.get("text", "")} + return recipients, meta + + +def make_webhook_server(listen: str, app_url: str, resolver: Resolver) -> ThreadingHTTPServer: + host, port = listen.rsplit(":", 1) + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + if length > MAX_BODY: + return self._reply(413, {"error": "message too large"}) + body = self.rfile.read(length) + try: + recipients, meta = webhook_message(body, self.headers.get("Content-Type", ""), + parse_qs(urlsplit(self.path).query)) + except (ValueError, KeyError) as e: + return self._reply(400, {"error": f"bad inbound payload: {e}"}) + if not recipients: + return self._reply(400, {"error": "no recipient"}) + deliveries = gateway(recipients, meta, resolver, app_url) + status = 503 if any(d.outcome == "tempfail" for d in deliveries) else 200 + self._reply(status, {"deliveries": [d.__dict__ for d in deliveries]}) + + def _reply(self, status, obj): + out = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + + def log_message(self, fmt, *a): + print(fmt % a, file=sys.stderr) + + return ThreadingHTTPServer((host, int(port)), Handler) + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="mas-gateway", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--statute", action="append", metavar="FILE", + help="pin a statute from local configuration (keyed by its namespace)") + ap.add_argument("--registry", metavar="FILE", help="local registry JSON") + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("pipe") + p.add_argument("--recipient", action="append", required=True) + p.add_argument("--app-url", required=True) + w = sub.add_parser("webhook") + w.add_argument("--listen", default="127.0.0.1:8026") + w.add_argument("--app-url", required=True) + args = ap.parse_args(argv) + registry = json.load(open(args.registry)) if args.registry else None + resolver = Resolver.with_statute_files(args.statute, local_registry=registry) + if args.cmd == "pipe": + return cmd_pipe(args, resolver) + srv = make_webhook_server(args.listen, args.app_url, resolver) + print(f"mas-gateway webhook on http://{args.listen} -> {args.app_url}", file=sys.stderr) + srv.serve_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dfa5ac8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "mas-core" +version = "0.1.0.dev0" +description = "Mail Address Semantics (MAS) v0.1 reference implementation" +requires-python = ">=3.11" +dependencies = ["jsonschema>=4.18"] + +[project.optional-dependencies] +test = ["pytest>=7"] + +[project.scripts] +mas = "mas_cli.main:main" +mas-gateway = "mas_gateway.main:main" + +[tool.setuptools] +packages = ["mas_core", "mas_conformance", "mas_cli", "mas_gateway"] + +[tool.setuptools.package-data] +mas_core = ["schema/*.json"] +mas_conformance = ["*.json", "statutes/*.json"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = [".", "examples/ticket_service"] diff --git a/tests/test_cli_and_static_statute.py b/tests/test_cli_and_static_statute.py new file mode 100644 index 0000000..f9efd23 --- /dev/null +++ b/tests/test_cli_and_static_statute.py @@ -0,0 +1,82 @@ +import importlib.util +import json +import threading +from pathlib import Path + +import pytest + +from mas_cli.main import main +from mas_core.discovery import Response, discover, http_transport +from mas_core.statute import lint + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "examples/statute/great-service.example" +STATUTE = STATIC / ".well-known/mas" + + +def run(capsys, *argv): + code = main(list(argv)) + return code, capsys.readouterr().out + + +def test_parse_json(capsys): + code, out = run(capsys, "parse", "ticket!callback", "--json") + d = json.loads(out) + assert code == 0 and d["required_profile"] == "P2" + assert run(capsys, "parse", "ticket!callback", "--profile", "p1")[0] == 1 + assert run(capsys, "parse", "ticket=PR42$1")[0] == 1 + + +def test_explain_separates_layers_and_never_guesses(capsys, monkeypatch): + import mas_cli.main as cli + monkeypatch.setattr(cli, "discover", lambda host, **kw: discover( + host, transport=lambda u, t: Response(404, {}, b""))) + code, out = run(capsys, "explain", "bernd.worsch@example.org") + assert code == 0 + for section in ("Syntax:", "Discovery:", "Semantics:", "Unknowns:"): + assert section in out + assert "no valid MAS statute found" in out and "NotFound" in out + assert "kind: opaque" in out and "person" not in out + + +def test_explain_with_statute_file(capsys): + code, out = run(capsys, "explain", "ticket=GS-41872+comments@great-service.example", + "--statute", str(STATUTE), "--json") + sem = json.loads(out)["semantics"] + assert sem["kind"] == "resource" and sem["resource_id"] == "GS-41872" and sem["facet"] == "comments" + + +def test_lint_exit_codes(capsys): + st = ROOT / "mas_conformance/statutes" + assert run(capsys, "lint-statute", str(STATUTE), "--host", "great-service.example")[0] == 0 + code, out = run(capsys, "lint-statute", str(st / "semantic-p2-disabled-modes.json")) + assert code == 1 and "semantic error" in out + code, out = run(capsys, "lint-statute", str(st / "schema-version.json")) + assert code == 1 and "schema error" in out + + +def test_conformance_command(capsys): + code, out = run(capsys, "test") + assert code == 0 and "FAIL" not in out + + +def test_static_statute_lints_clean(): + assert lint(json.loads(STATUTE.read_text()), "great-service.example") == [] + + +def test_static_statute_served_and_discovered(): + spec = importlib.util.spec_from_file_location("serve", ROOT / "examples/statute/serve.py") + serve = importlib.util.module_from_spec(spec) + spec.loader.exec_module(serve) + # Serve a copy whose namespace is the local test host. + srv = serve.make_server(str(STATIC), port=0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + port = srv.server_address[1] + r = discover("127.0.0.1", scheme="http", port=port) + assert not r.ok and r.kind.value == "NamespaceMismatch" # served for great-service.example + r = discover("great-service.example", scheme="http", port=port, + transport=lambda url, t: http_transport(url.replace("great-service.example", "127.0.0.1"), t)) + assert r.ok and r.statute.interaction_mode_enabled("callback") + finally: + srv.shutdown() diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..b2a29c0 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,150 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from mas_conformance import statute_document +from mas_core.discovery import ( + DiscoveryCache, + FailureKind, + Response, + discover, +) + +GS = statute_document("valid-great-service.json") +JSON = {"content-type": "application/json"} + + +def fake(routes): + calls = [] + + def transport(url, timeout): + calls.append(url) + r = routes.get(url) + if r is None: + return Response(404, {}, b"") + if isinstance(r, Exception): + raise r + return r + transport.calls = calls + return transport + + +def ok(doc, ctype="application/json"): + return Response(200, {"content-type": ctype}, json.dumps(doc).encode()) + + +def redirect(loc, status=301): + return Response(status, {"location": loc}, b"") + + +U = "https://great-service.example/.well-known/mas" + + +def test_success_exact_host(): + r = discover("great-service.example", transport=fake({U: ok(GS)})) + assert r.ok and r.statute.namespace == "great-service.example" + + +def test_no_parent_walk(): + t = fake({U: ok(GS)}) + r = discover("acme.great-service.example", transport=t) + assert not r.ok and r.kind is FailureKind.NotFound + assert t.calls == ["https://acme.great-service.example/.well-known/mas"] + + +@pytest.mark.parametrize("routes,kind", [ + ({U: Response(500, {}, b"")}, FailureKind.HttpFailure), + ({U: OSError("connection refused")}, FailureKind.TransportFailure), + ({U: ok(GS, "text/html")}, FailureKind.InvalidContentType), + ({U: Response(200, JSON, b"{not json")}, FailureKind.NotJson), + ({U: ok({**GS, "mas_version": "0.2"})}, FailureKind.UnsupportedVersion), + ({U: ok({**GS, "profiles": {"p1": True}})}, FailureKind.SchemaInvalid), + ({U: ok(statute_document("semantic-p2-disabled-modes.json"))}, FailureKind.SemanticInvalid), + ({U: ok({**GS, "namespace": "other.example"})}, FailureKind.NamespaceMismatch), + ({U: redirect("https://evil.example/.well-known/mas")}, FailureKind.CrossHostRedirect), + ({U: redirect("https://great-service.example:8443/mas")}, FailureKind.CrossHostRedirect), + ({U: redirect("http://great-service.example/.well-known/mas")}, FailureKind.SchemeDowngrade), + ({U: redirect("/.well-known/mas")}, FailureKind.TooManyRedirects), +]) +def test_typed_failures(routes, kind): + r = discover("great-service.example", transport=fake(routes)) + assert not r.ok and r.kind is kind, r + + +def test_same_origin_redirect_followed(): + alt = "https://great-service.example/static/mas.json" + r = discover("great-service.example", transport=fake({U: redirect("/static/mas.json", 302), alt: ok(GS)})) + assert r.ok + + +def _child(parent_url, **over): + return {**GS, "inherits": parent_url, **over} + + +def test_inheritance_via_discovery(): + P = "https://parent.example/.well-known/mas" + parent = {**GS, "namespace": "parent.example", + "resource_types": {"invoice": {"enabled": True}}, "addresses": {"boss": {"kind": "person"}}} + r = discover("great-service.example", transport=fake({U: ok(_child(P)), P: ok(parent)})) + assert r.ok and r.statute.resource_type_enabled("invoice") and r.statute.resource_type_enabled("ticket") + assert "boss" not in r.statute.addresses + + +def test_inheritance_cycle_via_discovery(): + P = "https://parent.example/.well-known/mas" + parent = {**GS, "namespace": "parent.example", "inherits": U} + r = discover("great-service.example", transport=fake({U: ok(_child(P)), P: ok(parent)})) + assert not r.ok and r.kind is FailureKind.InheritanceCycle + + +def test_inheritance_parent_failure_is_typed(): + P = "https://parent.example/.well-known/mas" + r = discover("great-service.example", transport=fake({U: ok(_child(P))})) + assert not r.ok and r.kind is FailureKind.InheritanceFailure and "NotFound" in r.detail + + +def test_cache_keeps_failures_typed_and_separate(): + now = [0.0] + cache = DiscoveryCache(success_ttl=100, failure_ttl=10, clock=lambda: now[0]) + routes = {} + t = fake(routes) + r1 = discover("great-service.example", transport=t, cache=cache) + r2 = discover("great-service.example", transport=t, cache=cache) + assert r1.kind is r2.kind is FailureKind.NotFound and r2.cached and len(t.calls) == 1 + routes[U] = ok(GS) + now[0] = 11 + r3 = discover("great-service.example", transport=t, cache=cache) + assert r3.ok and not r3.cached + now[0] = 50 + assert discover("great-service.example", transport=t, cache=cache).cached + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/.well-known/mas": + body = json.dumps({**GS, "namespace": "localhost"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *a): + pass + + +def test_local_http_fixture_server(): + srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + r = discover("localhost", scheme="http", port=srv.server_address[1]) + assert r.ok and r.statute.namespace == "localhost" + r = discover("LOCALHOST.", scheme="http", port=srv.server_address[1]) + assert r.ok + finally: + srv.shutdown() diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 0000000..cacf1d8 --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,164 @@ +"""Full path: raw mail -> gateway -> MAS event -> HTTP app -> ticket lifecycle.""" + +import json +import subprocess +import sys +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from mas_core.discovery import Response, discover +from mas_gateway.core import Resolver, build_event, gateway, message_meta_from_rfc822 +from mas_gateway.main import make_webhook_server +from ticket_service import TicketService, make_server + +ROOT = Path(__file__).resolve().parents[1] +STATUTE = ROOT / "examples/statute/great-service.example/.well-known/mas" +D = "great-service.example" +CUSTOMER = "customer@example.net" + + +def raw(to, subject, body="hello", sender=CUSTOMER, extra=""): + return (f"From: Customer <{sender}>\r\nTo: {to}\r\nSubject: {subject}\r\n" + f"Message-ID: <{abs(hash((to, subject, body)))}@example.net>\r\n{extra}\r\n{body}\r\n").encode() + + +def serve(server): + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +@pytest.fixture +def env(): + ids = iter(f"GS-{n}" for n in (41872, 41873, 41874)) + svc = TicketService(id_factory=lambda: next(ids)) + srv = serve(make_server(svc, port=0)) + resolver = Resolver.with_statute_files([STATUTE]) + app = f"http://127.0.0.1:{srv.server_address[1]}/mas-events" + + def send(to, subject, **kw): + [d] = gateway([to], message_meta_from_rfc822(raw(to, subject, **kw)), resolver, app) + return d + yield svc, send, app, resolver + srv.shutdown() + + +def test_canonical_lifecycle(env): + svc, send, *_ = env + # Flow A: create via P2 interaction intake + d = send(f"ticket!callback@{D}", "My router is broken") + assert d.status == 201 and d.response["resource_address"] == f"ticket=GS-41872@{D}" + assert d.response["requested_interaction"] == "callback" + [reply] = svc.outbox() + assert reply["mail_from"] == f"ticket=GS-41872@{D}" and reply["mail_to"] == CUSTOMER + assert "GS-41872" not in reply["subject"] # no identifier in the subject + + # Flow B: correlate purely by address; the subject is unrelated + d = send(f"ticket=GS-41872@{D}", "The problem happened again") + assert d.response == {"status": "correlated", "ticket_id": "GS-41872", "facet": None} + d = send(f"ticket=GS-41872+comments@{D}", "a comment") + assert d.response["facet"] == "comments" + d = send(f"ticket=GS-41872@{D}", "comment (facet stripped in transit)") + assert d.response["ticket_id"] == "GS-41872" # facet loss degrades safely + assert len(svc.ticket("GS-41872")["messages"]) == 4 + + # Flow C: closed resource stays addressable but immutable + assert svc.close("GS-41872") + d = send(f"ticket=GS-41872@{D}", "Hello again?") + assert d.response["status"] == "closed" and d.response["mutated"] is False + assert d.response["notified"] is True + t = svc.ticket("GS-41872") + assert t["status"] == "closed" + assert [m["disposition"] for m in t["messages"]][-1] == "archived-after-close" + d = send(f"ticket=GS-41872@{D}", "From a stranger", sender="other@example.org") + assert d.response["notified"] is False # no notice to non-participants + + # Flow D: unknown resource -> quarantined, no reply + before = len(svc.outbox()) + d = send(f"ticket=DOES-NOT-EXIST@{D}", "guess") + assert d.status == 202 and d.response["status"] == "unknown-resource" + assert len(svc.outbox()) == before and len(svc.quarantine()) == 1 + + +def test_no_auto_reply_to_automated_mail(env): + svc, send, *_ = env + send(f"ticket@{D}", "out of office", extra="Auto-Submitted: auto-replied\r\n") + assert svc.outbox() == [] + + +def test_interaction_not_enabled_is_not_honoured(env): + svc, send, *_ = env + d = send(f"ticket!chat@{D}", "please chat") + assert d.status == 201 and d.response["requested_interaction"] is None + assert "not available" in svc.outbox()[0]["body"] + + +def test_unconfirmed_namespace_is_not_acted_on(env): + svc, _, app, _ = env + resolver = Resolver(discover_fn=lambda host, **kw: discover( + host, transport=lambda u, t: Response(404, {}, b""))) + [d] = gateway([f"ticket=GS-41872@{D}"], {"from": CUSTOMER, "subject": "x"}, resolver, app) + assert d.status == 422 and d.response["status"] == "namespace-unconfirmed" + + +def test_non_mas_recipient_still_forwarded(env): + _, _, _, resolver = env + ev = build_event(f'"quoted"@{D}', {}, resolver) + assert ev["mas"]["valid"] is False and ev["recipient"] == f'"quoted"@{D}' + + +def test_pipe_transport_subprocess(env): + svc, _, app, _ = env + to = f"ticket!callback@{D}" + p = subprocess.run([sys.executable, "-m", "mas_gateway.main", "--statute", str(STATUTE), "pipe", + "--recipient", to, "--app-url", app], input=raw(to, "via pipe"), + capture_output=True, cwd=ROOT) + assert p.returncode == 0, p.stderr + assert svc.ticket("GS-41872")["requested_interaction"] == "callback" + p = subprocess.run([sys.executable, "-m", "mas_gateway.main", "--statute", str(STATUTE), "pipe", + "--recipient", to, "--app-url", "http://127.0.0.1:9/none"], + input=raw(to, "app down"), capture_output=True, cwd=ROOT) + assert p.returncode == 75 # tempfail, never a bounce + + +def test_webhook_input(env): + svc, _, app, resolver = env + gw = serve(make_webhook_server("127.0.0.1:0", app, resolver)) + try: + url = f"http://127.0.0.1:{gw.server_address[1]}/inbound" + body = json.dumps({"recipient": f"ticket!callback@{D}", "from": CUSTOMER, + "subject": "via webhook", "text": "hi"}).encode() + req = urllib.request.Request(url, body, {"Content-Type": "application/json"}) + with urllib.request.urlopen(req) as r: + [d] = json.loads(r.read())["deliveries"] + assert d["status"] == 201 and d["response"]["ticket_id"] == "GS-41872" + finally: + gw.shutdown() + + +def test_gateway_is_resource_type_agnostic(): + """Another resource type routes through the unchanged gateway.""" + got = [] + + class App(BaseHTTPRequestHandler): + def do_POST(self): + got.append(json.loads(self.rfile.read(int(self.headers["Content-Length"])))) + self.send_response(204) + self.end_headers() + + def log_message(self, *a): + pass + + srv = serve(ThreadingHTTPServer(("127.0.0.1", 0), App)) + try: + resolver = Resolver.with_statute_files([ROOT / "mas_conformance/statutes/valid-example.json"]) + gateway(["invoice=RE=2026-4711+dispute@coulomb.social"], {"from": CUSTOMER}, + resolver, f"http://127.0.0.1:{srv.server_address[1]}/") + mas = got[0]["mas"] + assert (mas["kind"], mas["resource_type"], mas["resource_id"], mas["facet"]) == \ + ("resource", "invoice", "RE=2026-4711", "dispute") + finally: + srv.shutdown() diff --git a/tests/test_parser_conformance.py b/tests/test_parser_conformance.py new file mode 100644 index 0000000..265ebe6 --- /dev/null +++ b/tests/test_parser_conformance.py @@ -0,0 +1,113 @@ +import json +import socket +from pathlib import Path + +import pytest + +from mas_conformance import address_fixtures, property_fixtures +from mas_conformance.runner import check_address_fixture, run_parser_corpus +from mas_core.generator import generate_local_part, resource_local_part +from mas_core.model import RequiredProfile +from mas_core.parser import ( + MasSyntaxError, + parse_address, + parse_local_part, + validate_profile, +) + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = address_fixtures() + property_fixtures() + + +def test_vendored_corpus_matches_spec_bundle(): + spec = json.loads((ROOT / "mas-v0.1-spec/conformance/addresses.json").read_text()) + assert spec == address_fixtures() + + +def test_vendored_schema_matches_spec_bundle(): + a = (ROOT / "mas-v0.1-spec/schema/mas-statute-v0.1.schema.json").read_bytes() + b = (ROOT / "mas_core/schema/mas-statute-v0.1.schema.json").read_bytes() + assert a == b + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=[f["id"] for f in FIXTURES]) +def test_fixture(fixture): + assert check_address_fixture(fixture) is None + + +def test_whole_corpus_passes(): + assert run_parser_corpus().ok + + +@pytest.fixture +def no_network(monkeypatch): + def deny(*a, **k): + raise AssertionError("parser attempted network I/O") + monkeypatch.setattr(socket, "socket", deny) + monkeypatch.setattr(socket, "create_connection", deny) + monkeypatch.setattr(socket, "getaddrinfo", deny) + + +def test_parser_performs_no_io(no_network, monkeypatch): + monkeypatch.setattr("builtins.open", lambda *a, **k: (_ for _ in ()).throw(AssertionError("file I/O"))) + for f in FIXTURES: + try: + parse_local_part(f["local_part"]) + except MasSyntaxError: + pass + parse_address("anna.mueller@acme.coulomb.social") + + +def test_maximal_parser_reports_required_profile(): + p = parse_local_part("ticket!callback") + assert p.required_profile is RequiredProfile.P2 + assert not validate_profile(p, "p1") + assert validate_profile(p, "P2") + assert parse_local_part("ticket=PR42+comments").required_profile is RequiredProfile.P1 + + +def test_combined_form_model(): + p = parse_local_part("ticket=PR42+comments!callback") + assert p.target.resource_type == "ticket" and p.target.resource_id == "PR42" + assert p.facet.value == "comments" and p.interaction.mode == "callback" + assert p.required_profile is RequiredProfile.P2 + assert not p.generator_allowed + + +def test_no_kind_inference_from_punctuation(): + for lp in ("bernd.worsch", "research-companion", "invoice_ingest"): + d = parse_local_part(lp).to_dict() + assert d["target_kind"] == "opaque" + assert not {"kind", "person", "actor", "function"} & set(d) + + +def test_facet_loss_degrades_to_base(): + p = parse_local_part("ticket=GS-41872+comments") + assert p.base == "ticket=GS-41872" + stripped = p.without_facet() + assert stripped.raw == "ticket=GS-41872" and stripped.target == p.target + + +def test_parse_address_domain(): + a = parse_address("ticket=GS-41872@Great-Service.Example") + assert a.domain == "great-service.example" + with pytest.raises(MasSyntaxError): + parse_address("no-domain") + + +def test_generator_rules(): + assert generate_local_part("ticket", interaction="callback") == "ticket!callback" + assert resource_local_part("ticket", "GS-41872") == "ticket=GS-41872" + with pytest.raises(MasSyntaxError): + generate_local_part("ticket=PR42", facet="comments", interaction="callback") + with pytest.raises(MasSyntaxError): + generate_local_part("ticket", facet="a+b") + with pytest.raises(MasSyntaxError): + generate_local_part("ticket", facet="") + with pytest.raises(MasSyntaxError): + resource_local_part("ticket", "x" * 60) + for f in FIXTURES: + if f["valid"] and f.get("generator_allowed") is False: + p = parse_local_part(f["local_part"]) + with pytest.raises(MasSyntaxError): + generate_local_part(str(p.target), facet=p.facet.value, interaction=p.interaction.mode) diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..542666a --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,97 @@ +import socket + +import pytest + +from mas_conformance import statute_document +from mas_core.parser import parse_address, parse_local_part +from mas_core.policy import resolve +from mas_core.statute import build_statute + +EX = build_statute(statute_document("valid-example.json")) +GS = build_statute(statute_document("valid-great-service.json")) + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + def deny(*a, **k): + raise AssertionError("policy attempted network I/O") + for name in ("socket", "create_connection", "getaddrinfo"): + monkeypatch.setattr(socket, name, deny) + + +def codes(sem): + return {f.code for f in sem.failures} + + +def test_no_statute_stays_opaque(): + s = resolve(parse_address("bernd.worsch@example.org")) + assert s.kind == "opaque" and s.kind_source == "none" and not s.namespace_confirmed + assert any("punctuation" in u for u in s.unknowns) + + +def test_statute_registry_classifies(): + assert resolve(parse_local_part("bernd.worsch"), EX).kind == "person" + assert resolve(parse_local_part("research-companion"), EX).kind == "actor" + assert resolve(parse_local_part("happy-helper.v2"), EX).kind == "opaque" + + +def test_registry_uses_base_target_not_faceted_address(): + s = resolve(parse_local_part("support+urgent"), EX) + assert s.kind == "function" and s.registry_key == "support" and s.facet == "urgent" + + +def test_resource_with_statute(): + s = resolve(parse_local_part("ticket=PR42"), EX) + assert (s.kind, s.resource_type, s.resource_id) == ("resource", "ticket", "PR42") + + +def test_resource_without_statute_is_unconfirmed(): + s = resolve(parse_local_part("ticket=PR42")) + assert s.kind == "opaque" and s.ok and s.resource_type == "ticket" + assert any("no application semantics" in u for u in s.unknowns) + + +def test_unknown_and_disabled_resource_types(): + assert codes(resolve(parse_local_part("order=1"), GS)) == {"UnknownResourceType"} + doc = statute_document("valid-great-service.json") + doc["resource_types"]["ticket"]["enabled"] = False + assert codes(resolve(parse_local_part("ticket=1"), build_statute(doc))) == {"ResourceTypeNotEnabled"} + + +def test_profile_not_enabled(): + doc = statute_document("valid-great-service.json") + doc["profiles"]["p2"] = False + doc["interaction_modes"] = {} + s = resolve(parse_local_part("ticket!callback"), build_statute(doc)) + assert codes(s) == {"ProfileNotEnabled"} + assert s.parsed.required_profile.value == "P2" # syntax still valid + + +def test_interaction_mode(): + assert resolve(parse_local_part("ticket!callback"), GS).ok + assert codes(resolve(parse_local_part("ticket!chat"), GS)) == {"InteractionModeNotEnabled"} + assert any("unconfirmed" in u for u in resolve(parse_local_part("ticket!callback")).unknowns) + + +def test_equivalence_only_when_declared(): + assert resolve(parse_local_part("Bernd.Worsch"), EX).kind == "opaque" + doc = statute_document("valid-example.json") + doc["equivalence"] = {"local_part_case": "ascii-insensitive", "dot_folding": True} + st = build_statute(doc) + s = resolve(parse_local_part("Bernd.Worsch"), st) + assert s.kind == "person" and s.equivalence_key == "berndworsch" + assert resolve(parse_local_part("ticket=GS.1"), st).equivalence_key == "ticket=gs.1" + + +def test_local_registry_fallback_and_precedence(): + local = {"addresses": {"support": {"kind": "group"}, "ops": {"kind": "system"}}, + "resource_types": {"order": {"enabled": True}}} + assert resolve(parse_local_part("support"), EX, local).kind == "function" # statute wins + s = resolve(parse_local_part("ops"), EX, local) + assert (s.kind, s.kind_source) == ("system", "local-registry") + assert resolve(parse_local_part("order=7"), None, local).kind == "resource" + + +def test_deterministic(): + p = parse_local_part("ticket=PR42+comments") + assert resolve(p, EX) == resolve(p, EX) diff --git a/tests/test_statute.py b/tests/test_statute.py new file mode 100644 index 0000000..e6b0381 --- /dev/null +++ b/tests/test_statute.py @@ -0,0 +1,77 @@ +import pytest + +from mas_conformance import statute_cases, statute_document +from mas_core.statute import ( + ERROR, + WARNING, + InheritanceCycle, + StatuteInvalid, + build_statute, + lint, + resolve_inheritance, +) + +CASES = statute_cases() + + +@pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES]) +def test_statute_case(case): + doc = statute_document(case["file"]) + findings = lint(doc, case["host"]) + errors = [f for f in findings if f.severity == ERROR] + if case["expect"] == "valid": + assert not errors, errors + build_statute(doc, case["host"]) + assert set(case.get("warnings", [])) <= {f.code for f in findings if f.severity == WARNING} + else: + assert errors and {f.layer for f in errors} == {case["expect"]} + assert set(case.get("codes", [])) <= {f.code for f in errors} + with pytest.raises(StatuteInvalid) as exc: + build_statute(doc, case["host"]) + assert exc.value.layer == case["expect"] + + +def test_schema_valid_but_semantically_rejected(): + doc = statute_document("semantic-p2-disabled-modes.json") + assert not [f for f in lint(doc) if f.layer == "schema"] + with pytest.raises(StatuteInvalid): + build_statute(doc) + + +def _doc(ns, inherits=None, **over): + d = {"mas_version": "0.1", "namespace": ns, "profiles": {"p1": True, "p2": True}, + "equivalence": {"local_part_case": "exact", "dot_folding": False}} + if inherits: + d["inherits"] = inherits + d.update(over) + return d + + +def url(ns): + return f"https://{ns}/.well-known/mas" + + +def test_inheritance_defaults_only(): + parent = build_statute(_doc("parent.example", + resource_types={"ticket": {"enabled": True}, "invoice": {"enabled": True}}, + interaction_modes={"callback": {"enabled": True}}, + addresses={"support": {"kind": "function"}})) + child = build_statute(_doc("child.example", url("parent.example"), + resource_types={"invoice": {"enabled": False}})) + merged = resolve_inheritance(child, url("child.example"), lambda u: parent) + assert merged.namespace == "child.example" + assert merged.resource_type_enabled("ticket") # inherited default + assert not merged.resource_type_enabled("invoice") # child overrides + assert merged.interaction_mode_enabled("callback") + assert "support" not in merged.addresses # registry never inherits + assert merged.inherited_from == (url("parent.example"),) + + +@pytest.mark.parametrize("length", [2, 3, 4]) +def test_inheritance_cycles(length): + names = [f"n{i}.example" for i in range(length)] + docs = {url(n): build_statute(_doc(n, url(names[(i + 1) % length]))) + for i, n in enumerate(names)} + with pytest.raises(InheritanceCycle) as exc: + resolve_inheritance(docs[url(names[0])], url(names[0]), docs.__getitem__) + assert exc.value.chain[0] == exc.value.chain[-1] diff --git a/workplans/MAS-WP-0001-statehub-bootstrap.md b/workplans/MAS-WP-0001-statehub-bootstrap.md index a3e59b0..78e8049 100644 --- a/workplans/MAS-WP-0001-statehub-bootstrap.md +++ b/workplans/MAS-WP-0001-statehub-bootstrap.md @@ -4,7 +4,7 @@ type: workplan title: "Bootstrap State Hub integration" domain: communication repo: mail-address-semantics -status: active +status: done owner: codex topic_slug: coulomb-social created: "2026-10-08" @@ -20,7 +20,7 @@ Mail Address Semantics (MAS) v0.1 reference implementation: parser, statute disc ```task id: MAS-WP-0001-T01 -status: todo +status: done priority: high state_hub_task_id: "84104dd8-660c-50b1-b096-41f11df0ce9c" ``` @@ -32,7 +32,7 @@ Replace generated placeholders with repo-specific facts where needed. ```task id: MAS-WP-0001-T02 -status: todo +status: done priority: high state_hub_task_id: "67aefc29-eac5-5032-9c73-94d529e037e7" ``` diff --git a/workplans/MAS-WP-0002-reference-experiment.md b/workplans/MAS-WP-0002-reference-experiment.md index 5d713f4..441e77e 100644 --- a/workplans/MAS-WP-0002-reference-experiment.md +++ b/workplans/MAS-WP-0002-reference-experiment.md @@ -4,7 +4,7 @@ type: workplan title: "MAS v0.1 reference experiment" domain: communication repo: mail-address-semantics -status: ready +status: active owner: claude topic_slug: coulomb-social created: "2026-10-08" @@ -38,7 +38,7 @@ The plan deliberately avoids parallel language implementations and infrastructur ```task id: MAS-WP-0002-T01 -status: todo +status: done priority: high state_hub_task_id: "4cd7534b-0175-5213-851b-1416cabc4366" ``` @@ -94,7 +94,7 @@ runs the existing fixture corpus. ```task id: MAS-WP-0002-T02 -status: todo +status: done priority: high state_hub_task_id: "099c957c-1598-5888-b1f5-74e8e90d38fc" ``` @@ -237,7 +237,7 @@ Registry lookup should operate on the base target. ```task id: MAS-WP-0002-T03 -status: todo +status: done priority: high state_hub_task_id: "90f803d5-9956-5a67-9100-629c5c1a1376" ``` @@ -347,7 +347,7 @@ A malformed but schema-valid statute can still be rejected by semantic linting. ```task id: MAS-WP-0002-T04 -status: todo +status: done priority: high state_hub_task_id: "c220fde2-3f6f-5d32-9dd8-dfdadf2f636a" ``` @@ -424,7 +424,7 @@ Discovery is deterministic enough to test against a local HTTP fixture server. ```task id: MAS-WP-0002-T05 -status: todo +status: done priority: high state_hub_task_id: "940a1636-a0de-5697-8e8e-cb4181cf9b76" ``` @@ -530,7 +530,7 @@ The complete policy test suite runs with network disabled. ```task id: MAS-WP-0002-T06 -status: todo +status: done priority: high state_hub_task_id: "6aedba08-4220-599e-8ad3-01fa99cf3c16" ``` @@ -612,7 +612,7 @@ A human can understand exactly why an address received its interpretation. ```task id: MAS-WP-0002-T07 -status: todo +status: done priority: medium state_hub_task_id: "bb1e8e08-83a8-5122-a556-26b3d7ac6017" ``` @@ -654,7 +654,7 @@ interaction_modes: ```task id: MAS-WP-0002-T08 -status: todo +status: done priority: medium state_hub_task_id: "896df692-3b8d-50ea-bdee-262e4e4854b7" ``` @@ -719,7 +719,7 @@ The same gateway could theoretically route another MAS resource type without cod ```task id: MAS-WP-0002-T09 -status: todo +status: done priority: medium state_hub_task_id: "35069786-a79f-5256-a8c9-a0637bcebd51" ``` @@ -812,9 +812,11 @@ The service must handle this deliberately and without generating abusive backsca ## Milestone 9 — Experiment review +> First-pass findings: `docs/experiment-review.md`. Open item: live MTA/provider test (Email infrastructure question). + ```task id: MAS-WP-0002-T10 -status: todo +status: in_progress priority: medium state_hub_task_id: "31bbfb56-5541-5ace-aff7-94ed6f1df57a" ```