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:
parent
d766d61e7f
commit
499ddce015
4 changed files with 187 additions and 6 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
81
tests/test_compose.py
Normal 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"])
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue