MAS-WP-0005-T09: generator destination transforms with round-trip refusal; example profiles

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 252470@bnt-lap001
Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
This commit is contained in:
tegwick 2026-10-18 22:26:26 +02:00
parent eb10f54fd2
commit c3e2f05ee5
5 changed files with 200 additions and 5 deletions

View file

@ -253,7 +253,7 @@ profile), MAS-WP-0005 (spec errata and v0.2 drafts), MAS-WP-0006
|---|---|---|
| Postfix/Exim integration | **Yes.** Default Postfix drops every P2 address, and the correct relay config is non-obvious | Build first: a documented, tested Postfix profile (later Exim) based on `deploy/railiance/entrypoint.sh` |
| Spec v0.1.1 errata | **Yes**, SCP-1 to SCP-7 (SCP-6 is an additive feature and could go to v0.2) | Do alongside the integration |
| Address composer | Partly. Mail clients accepted hand-typed addresses, but the IONOS forwarding form refused `=` and lower-cased input | Defer the tool; first settle SCP-6/SCP-7, then `mas generate` emits the forwarder-safe form |
| Address composer | Partly. Mail clients accepted hand-typed addresses, but the IONOS forwarding form refused `=` and lower-cased input | Defer the tool; first settle SCP-6/SCP-7, then the generator emits the forwarder-safe form (`mas_core.generator.for_destination`, MAS-WP-0005 T09); the CLI/UI is MAS-WP-0006 |
| Mail-client inspector | Not shown; nothing was rewritten that a user would need to see | Defer |
| TypeScript / Go implementations | Not shown; no consumer outside Python yet | Defer until a second consumer exists |
| Signed statutes | Not shown; pinned local statutes covered the operator case (see Discovery) | Defer |

View file

@ -1,4 +1,4 @@
"""Canonical MAS v0.1 local-part generator.
"""Canonical MAS local-part generator, with v0.2 destination transforms.
Generators are stricter than parsers: they never emit both a facet and an
interaction, and they enforce the 64-octet SMTP limit after encoding.
@ -6,10 +6,15 @@ interaction, and they enforce the 64-octet SMTP limit after encoding.
from __future__ import annotations
from typing import Optional
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable, Optional
from .model import ParsedLocalPart, ResourceTarget, _render
from .parser import MasSyntaxError, parse_local_part
if TYPE_CHECKING:
from .statute import Statute
def generate_local_part(target: str, *, facet: Optional[str] = None,
interaction: Optional[str] = None) -> str:
@ -36,3 +41,95 @@ def resource_local_part(resource_type: str, resource_id: str, *, facet: Optional
raise MasSyntaxError("invalid-resource-type", "resource type must not contain '='")
return generate_local_part(f"{resource_type}={resource_id}", facet=facet,
interaction=interaction)
# -- v0.2 destination transforms (spec v0.2 section 12.1) --------------------------
TRANSFORMS = ("drop-facet", "drop-interaction", "interaction-alternate", "resource-alternate", "lowercase")
LOSSY = frozenset({"drop-facet", "drop-interaction"})
# Example destination profiles (implementation configuration, not spec):
# what each destination needs, as observed in MAS-WP-0003.
DESTINATION_PROFILES: dict[str, tuple[str, ...]] = {
"canonical": (),
"ionos-forward": ("resource-alternate", "lowercase"), # refuses '=', forces lower case
"google-groups": ("lowercase",), # stores members lower-cased
}
class CannotExpress(MasSyntaxError):
"""The address cannot be written for this destination without being misread."""
@dataclass(frozen=True)
class DestinationAddress:
local_part: str
applied: tuple[str, ...]
warnings: tuple[str, ...] = ()
def for_destination(local_part: str, statute: "Statute", needs: Iterable[str] = (), *,
allow_lossy: bool = False) -> DestinationAddress:
"""Write a canonical local-part for a destination that needs `needs`.
Transforms apply only where they change something, lossy ones only with
`allow_lossy`. The result is read back with the receiving policy; if the
namespace would not resolve it to the same resource, facet and
interaction, the generator refuses (CannotExpress) instead of emitting it.
"""
from .policy import resolve # policy imports the parser; keep the generator import-light
needs = tuple(needs)
unknown = sorted(set(needs) - set(TRANSFORMS))
if unknown:
raise ValueError(f"unknown transform(s): {', '.join(unknown)}")
if LOSSY & set(needs) and not allow_lossy:
raise CannotExpress("lossy-not-allowed", "drop transforms need allow_lossy=True")
parsed = parse_local_part(local_part)
target, facet, interaction = parsed.target, parsed.facet, parsed.interaction
applied: list[str] = []
warnings: list[str] = []
if "drop-facet" in needs and facet is not None:
facet = None
applied.append("drop-facet")
if "drop-interaction" in needs and interaction is not None:
warnings.append(f"interaction {interaction.mode!r} dropped; the request mode is lost")
interaction = None
applied.append("drop-interaction")
text = str(target)
if "resource-alternate" in needs and isinstance(target, ResourceTarget):
if not statute.alt_resource:
raise CannotExpress("transform-not-permitted", "statute does not enable the '--' stand-in")
if "=" in target.resource_id:
raise CannotExpress("cannot-express", "resource id itself contains '='")
text = f"{target.resource_type}--{target.resource_id}"
applied.append("resource-alternate")
if facet is not None:
text += "+" + facet.value
if interaction is not None:
if "interaction-alternate" in needs:
if not statute.alt_interaction:
raise CannotExpress("transform-not-permitted", "statute does not enable the '---' stand-in")
text += "---" + interaction.mode
applied.append("interaction-alternate")
else:
text += "!" + interaction.mode
if "lowercase" in needs and text != text.lower():
text = text.lower()
applied.append("lowercase")
try:
out = parse_local_part(text) # grammar and the 64-octet limit, after transforms
except MasSyntaxError as e:
raise CannotExpress(e.code, f"not expressible for this destination: {e}") from e
expected = resolve(ParsedLocalPart(raw=_render(target, facet, interaction), target=target,
facet=facet, interaction=interaction), statute)
got = resolve(out, statute)
if (got.equivalence_key, got.facet, got.interaction) != \
(expected.equivalence_key, expected.facet, expected.interaction):
raise CannotExpress("would-be-misread",
f"{text!r} would be read as {got.to_dict()['canonical_local_part']!r}")
return DestinationAddress(text, tuple(applied), tuple(warnings))

78
tests/test_destination.py Normal file
View file

@ -0,0 +1,78 @@
"""v0.2 destination transforms (spec v0.2 section 12.1)."""
import pytest
from mas_conformance import statute_document
from mas_core.generator import DESTINATION_PROFILES, CannotExpress, for_destination
from mas_core.statute import build_statute
def statute(resource=True, interaction=True, id_case="insensitive", version="0.2"):
doc = statute_document("valid-great-service.json")
doc["mas_version"] = version
if version == "0.2":
doc["alternate_syntax"] = {"resource": resource, "interaction": interaction}
doc["equivalence"]["resource_id_case"] = id_case
return build_statute(doc, "great-service.example")
V2 = statute()
IONOS = DESTINATION_PROFILES["ionos-forward"]
def test_canonical_when_nothing_is_needed():
r = for_destination("ticket=GS-1+comments", V2, ())
assert (r.local_part, r.applied) == ("ticket=GS-1+comments", ())
def test_ionos_forward_profile():
# the MAS-WP-0003 case: IONOS refuses '=' and forces lower case
r = for_destination("ticket=GS-85QJ8WC09P+comments", V2, IONOS)
assert r.local_part == "ticket--gs-85qj8wc09p+comments"
assert r.applied == ("resource-alternate", "lowercase")
def test_transforms_apply_only_where_they_change_something():
assert for_destination("support", V2, IONOS).applied == ()
assert for_destination("ticket=gs-1", V2, IONOS).applied == ("resource-alternate",)
def test_interaction_alternate():
assert for_destination("ticket!callback", V2, ("interaction-alternate",)).local_part == "ticket---callback"
@pytest.mark.parametrize("st, code", [
(statute(resource=False), "transform-not-permitted"), # no '--' switch
(statute(version="0.1"), "transform-not-permitted"), # v0.1 statute
(statute(id_case="exact"), "would-be-misread"), # lower-casing changes the id
])
def test_refuses_what_the_namespace_would_misread(st, code):
with pytest.raises(CannotExpress) as e:
for_destination("ticket=GS-1", st, IONOS)
assert e.value.code == code
def test_refuses_id_containing_equals():
with pytest.raises(CannotExpress) as e:
for_destination("ticket=RE=2026-4711", V2, ("resource-alternate",))
assert e.value.code == "cannot-express"
def test_refuses_case_change_in_facet():
with pytest.raises(CannotExpress) as e:
for_destination("ticket=gs-1+Comments", V2, ("lowercase",))
assert e.value.code == "would-be-misread"
def test_length_is_checked_after_transforms():
lp = "ticket=" + "a" * 57 # 64 octets canonical, 65 with '--'
assert len(lp) == 64
with pytest.raises(CannotExpress) as e:
for_destination(lp, V2, ("resource-alternate",))
assert e.value.code == "too-long"
def test_lossy_transforms_need_explicit_permission():
with pytest.raises(CannotExpress):
for_destination("ticket=gs-1+comments", V2, ("drop-facet",))
r = for_destination("ticket!callback", V2, ("drop-interaction",), allow_lossy=True)
assert r.local_part == "ticket" and r.warnings

View file

@ -293,7 +293,7 @@ the stand-in constraints. Statute-aware fixtures.
```task
id: MAS-WP-0005-T09
status: todo
status: done
priority: medium
state_hub_task_id: "853d1b76-8b7f-5234-aacc-dc1fb5d155c2"
```
@ -303,6 +303,25 @@ canonical form when allowed; refuse when the namespace would not accept the
result; re-check the length after transforms. Include an example destination
profile `ionos-forward`. This is the base for the composer (MAS-WP-0006).
- 2026-10-18 done: `mas_core.generator.for_destination(local_part, statute,
needs, allow_lossy=False)` returns `DestinationAddress(local_part, applied,
warnings)`.
- It applies only the transforms that change something.
- Lossy transforms need `allow_lossy`; `drop-interaction` adds a warning.
- Refusals raise `CannotExpress`: `transform-not-permitted` (statute
switch off, or a v0.1 statute), `cannot-express` (id contains `=`),
`too-long` (after transforms) and `would-be-misread`.
- Safety is a round trip: the result is resolved with the receiving
policy and must give the same equivalence key, facet and interaction.
This catches lower-casing under `resource_id_case: exact` and case
changes in facets.
- Example profiles: `canonical`, `ionos-forward` (resource-alternate +
lowercase), `google-groups` (lowercase).
- 11 new tests; 179 pass.
- Correction: there is no `mas generate` CLI command. The review's gate
table and MAS-WP-0006 now point to `for_destination`; the CLI/UI is
MAS-WP-0006.
## v0.2 live check: IONOS forward with stand-ins
```task

View file

@ -35,7 +35,8 @@ Define what the composer does: from a statute and an intent (resource,
facet, interaction), emit the canonical address and, when the statute
declares one, the forwarder-safe form (lower case, `--` separator). Warn
about the 64-octet budget and facet/interaction exclusivity. Decide
whether it is an extension of `mas generate`, a web page, or both.
whether it is a new CLI command (the CLI has no generate command yet), a web page,
or both. The library side exists: `mas_core.generator.for_destination` (MAS-WP-0005 T09).
## Implement and test