diff --git a/README.md b/README.md index 7da3a3c..af8b373 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ reference experiment (see `INTENT.md` and `workplans/MAS-WP-0002-*`). |------|------| | `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_cli/` | `mas parse / explain / discover / compose / 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 | @@ -23,11 +23,13 @@ reference experiment (see `INTENT.md` and `workplans/MAS-WP-0002-*`). ```bash uv venv && uv pip install -e '.[test]' -.venv/bin/pytest # full suite, offline +.venv/bin/pytest # full suite, offline (Postfix probe needs Docker) .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 +.venv/bin/mas compose mas.coulomb.social ticket=GS-1 --facet comments --for google-groups \ + --statute examples/statute/mas.coulomb.social/.well-known/mas # canonical + forwarder-safe ``` Local end-to-end run: diff --git a/mas_cli/main.py b/mas_cli/main.py index 21577bc..a9dc12d 100644 --- a/mas_cli/main.py +++ b/mas_cli/main.py @@ -9,6 +9,8 @@ from pathlib import Path from mas_core import MAS_VERSION, __version__ from mas_core.discovery import Discovered, discover +from mas_core.generator import DESTINATION_PROFILES, CannotExpress, for_destination, generate_local_part +from mas_core.model import ResourceTarget 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 @@ -120,6 +122,73 @@ def cmd_explain(args): return 0 +MAX_OCTETS = 64 +TIGHT_OCTETS = 16 # fewer left than this: warn, stand-ins and forwarders add octets + + +def _forwarder_needs(statute, local_part): + """The forwarder-safe form (spec v0.2 section 12.1): the stand-ins the + statute declares, then lower case.""" + p = parse_local_part(local_part) + needs = [] + if statute.alt_resource and isinstance(p.target, ResourceTarget): + needs.append("resource-alternate") + if statute.alt_interaction and p.interaction is not None: + needs.append("interaction-alternate") + return tuple(needs) + ("lowercase",) + + +def cmd_compose(args): + try: + local = generate_local_part(args.target, facet=args.facet, interaction=args.interaction) + except MasSyntaxError as e: + hint = (" a facet and an interaction are exclusive: pick one, or address the" + " resource and state the mode in the message\n" + if e.code == "generator-facet-and-interaction" else "") + _out(args, {"ok": False, "error": e.code, "message": str(e)}, + f"cannot compose [{e.code}]: {e}\n{hint}".rstrip()) + return 1 + disc = None if args.no_discover else _discover(args, args.domain) + statute = disc.statute if disc is not None and disc.ok else None + warnings = [] + if disc is not None and not disc.ok: + warnings.append(f"no valid MAS statute for {args.domain} ({disc.kind.value}); " + "the receiver may read this as ordinary email") + if statute is not None: + sem = resolve(parse_local_part(local), statute) + warnings += [f"{f.code}: {f.message}" for f in sem.failures] + octets = len(local.encode()) + if MAX_OCTETS - octets < TIGHT_OCTETS: + warnings.append(f"{octets}/{MAX_OCTETS} octets: little room left for forwarders") + + forms = {} + if statute is not None: + wanted = {name: DESTINATION_PROFILES[name] for name in args.destination} + if statute.alt_resource or statute.alt_interaction: + wanted = {"forwarder-safe": _forwarder_needs(statute, local), **wanted} + for name, needs in wanted.items(): + try: + r = for_destination(local, statute, needs) + forms[name] = {"local_part": r.local_part, "address": f"{r.local_part}@{args.domain}", + "applied": list(r.applied), "warnings": list(r.warnings)} + except CannotExpress as e: + forms[name] = {"local_part": None, "error": e.code, "message": str(e)} + elif args.destination: + warnings.append("destination forms need the statute; none was used") + + lines = [f"{local}@{args.domain}", f" octets: {octets}/{MAX_OCTETS} (local-part)"] + for name, f in forms.items(): + if f["local_part"] is None: + lines.append(f" {name}: cannot express [{f['error']}]: {f['message']}") + else: + how = f" ({', '.join(f['applied'])})" if f["applied"] else " (unchanged)" + lines.append(f" {name}: {f['address']}{how}") + lines += [f" warning: {w}" for w in warnings] + _out(args, {"ok": True, "local_part": local, "address": f"{local}@{args.domain}", + "octets": octets, "forms": forms, "warnings": warnings}, "\n".join(lines)) + return 0 + + def cmd_lint(args): worst = 0 reports = [] @@ -203,6 +272,19 @@ def build_parser(): p.add_argument("--registry", metavar="FILE", help="local registry JSON (addresses/resource_types)") p.set_defaults(func=cmd_explain) + p = common(sub.add_parser("compose", help="write the canonical address for a target, and its " + "forwarder-safe form")) + p.add_argument("domain", help="receiving domain (its statute is discovered)") + p.add_argument("target", help="resource TYPE=ID, or a registry name such as 'ticket'") + p.add_argument("--facet", help="facet (the part after '+')") + p.add_argument("--interaction", metavar="MODE", help="requested interaction mode (P2 '!MODE')") + p.add_argument("--for", dest="destination", action="append", default=[], + choices=sorted(set(DESTINATION_PROFILES) - {"canonical"}), + help="also write the form for this destination (repeatable)") + disc_opts(p) + p.add_argument("--no-discover", action="store_true", help="skip discovery (canonical form only)") + p.set_defaults(func=cmd_compose) + 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)") diff --git a/tests/test_compose.py b/tests/test_compose.py new file mode 100644 index 0000000..0ee4013 --- /dev/null +++ b/tests/test_compose.py @@ -0,0 +1,81 @@ +"""`mas compose` (MAS-WP-0006), with the forms seen in MAS-WP-0003.""" +import json +from pathlib import Path + +import pytest + +from mas_cli.main import main +from mas_core.discovery import Response, discover + +ROOT = Path(__file__).resolve().parents[1] +LIVE = str(ROOT / "examples/statute/mas.coulomb.social/.well-known/mas") # v0.2, stand-ins on +V01 = str(ROOT / "examples/statute/great-service.example/.well-known/mas") # v0.1, no stand-ins +D = "mas.coulomb.social" + + +def compose(capsys, *argv, statute=LIVE, domain=D): + code = main(["compose", domain, *argv, "--statute", statute, "--json"]) + return code, json.loads(capsys.readouterr().out) + + +@pytest.mark.parametrize("argv, canonical, safe", [ + # IONOS forwarding refused '=' and forced lower case; Google Groups lower-cased. + (["ticket=GS-85QJ8WC09P", "--facet", "comments"], + "ticket=GS-85QJ8WC09P+comments", "ticket--gs-85qj8wc09p+comments"), + (["ticket=GS-85QJ8WC09P"], "ticket=GS-85QJ8WC09P", "ticket--gs-85qj8wc09p"), + (["ticket", "--interaction", "callback"], "ticket!callback", "ticket---callback"), + (["support"], "support", "support"), +]) +def test_canonical_and_forwarder_safe(capsys, argv, canonical, safe): + code, d = compose(capsys, *argv) + assert code == 0 and d["local_part"] == canonical and d["address"] == f"{canonical}@{D}" + assert d["forms"]["forwarder-safe"]["local_part"] == safe + assert d["warnings"] == [] + + +def test_named_destinations(capsys): + code, d = compose(capsys, "ticket=GS-85QJ8WC09P", "--facet", "comments", + "--for", "google-groups", "--for", "ionos-forward") + assert d["forms"]["google-groups"]["local_part"] == "ticket=gs-85qj8wc09p+comments" + assert d["forms"]["ionos-forward"]["applied"] == ["resource-alternate", "lowercase"] + + +def test_id_containing_equals_has_no_forwarder_safe_form(capsys): + code, d = compose(capsys, "ticket=RE=2026-4711") + assert code == 0 and d["local_part"] == "ticket=RE=2026-4711" + assert d["forms"]["forwarder-safe"] == {"local_part": None, "error": "cannot-express", + "message": "resource id itself contains '='"} + + +def test_facet_and_interaction_are_exclusive(capsys): + code = main(["compose", D, "ticket=GS-1", "--facet", "c", "--interaction", "callback", + "--statute", LIVE]) + out = capsys.readouterr().out + assert code == 1 and "generator-facet-and-interaction" in out and "exclusive" in out + + +def test_octet_budget(capsys): + code, d = compose(capsys, "ticket=" + "X" * 50) + assert code == 0 and d["octets"] == 57 + assert any("57/64 octets" in w for w in d["warnings"]) + code = main(["compose", D, "ticket=" + "X" * 60, "--statute", LIVE]) + assert code == 1 and "64" in capsys.readouterr().out + + +def test_statute_mismatch_is_a_warning(capsys): + code, d = compose(capsys, "ticket=GS-1", "--interaction", "chat") + assert code == 0 and any(w.startswith("InteractionModeNotEnabled") for w in d["warnings"]) + + +def test_no_forwarder_safe_form_without_stand_ins(capsys): + code, d = compose(capsys, "ticket=PR42", statute=V01, domain="great-service.example") + assert code == 0 and d["forms"] == {} + + +def test_without_statute(capsys, monkeypatch): + import mas_cli.main as cli + monkeypatch.setattr(cli, "discover", lambda host, **kw: discover( + host, transport=lambda u, t: Response(404, {}, b""))) + assert main(["compose", D, "ticket=GS-1", "--for", "google-groups", "--json"]) == 0 + d = json.loads(capsys.readouterr().out) + assert d["forms"] == {} and any("no valid MAS statute" in w for w in d["warnings"]) diff --git a/workplans/MAS-WP-0006-address-composer.md b/workplans/MAS-WP-0006-address-composer.md index d8d6113..c535296 100644 --- a/workplans/MAS-WP-0006-address-composer.md +++ b/workplans/MAS-WP-0006-address-composer.md @@ -4,11 +4,11 @@ type: workplan title: "MAS address composer" domain: communication repo: mail-address-semantics -status: proposed +status: finished owner: claude topic_slug: coulomb-social created: "2026-10-18" -updated: "2026-10-18" +updated: "2026-10-19" origin: residual origin_ref: MAS-WP-0003 state_hub_workstream_id: "0934280a-f85a-55f1-a17c-61c5255b5e3b" @@ -26,7 +26,7 @@ outcome in MAS-WP-0005. ```task id: MAS-WP-0006-T01 -status: todo +status: done priority: medium state_hub_task_id: "13071dfc-fbaa-503e-bd0d-ddd6c2377758" ``` @@ -42,10 +42,26 @@ or both. The library side exists: `mas_core.generator.for_destination` (MAS-WP-0 ```task id: MAS-WP-0006-T02 -status: todo +status: done priority: medium state_hub_task_id: "69cb22fe-9995-55c3-91de-63eab5c09282" ``` Build it on `mas_core.generator`, with fixtures for the forms seen in MAS-WP-0003. + +## Outcome + +- Interface (T01): a CLI command only, `mas compose DOMAIN TARGET [--facet F] + [--interaction MODE] [--for DESTINATION] [--statute FILE | --no-discover]`. + There is no web page: it would need a second parser in JS, and nothing + asks for one yet. TARGET is `TYPE=ID` or a registry name. +- Output: the canonical address with its octet count. If the statute + declares stand-ins, also the forwarder-safe form (stand-ins, then lower + case). `--for` adds the named destination profiles. The composer warns + when fewer than 16 of the 64 octets are left, and when the statute would + not handle the address. It refuses facet + interaction (exit 1). A form + the namespace would misread is reported as "cannot express". +- Tests (T02): `tests/test_compose.py`, using the MAS-WP-0003 forms + (IONOS `ticket--gs-85qj8wc09p+comments`, Google Groups lower case, + `ticket---callback`, `ticket=RE=2026-4711` not expressible).