MAS-WP-0006: mas compose (canonical + forwarder-safe address)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
tegwick 2026-10-19 09:27:31 +02:00
parent d766d61e7f
commit 499ddce015
4 changed files with 187 additions and 6 deletions

View file

@ -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:

View file

@ -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)")

81
tests/test_compose.py Normal file
View file

@ -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"])

View file

@ -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).