diff --git a/docs/experiment-review.md b/docs/experiment-review.md index 4d0e9b0..0b984b6 100644 --- a/docs/experiment-review.md +++ b/docs/experiment-review.md @@ -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 | diff --git a/mas_core/generator.py b/mas_core/generator.py index 2d34687..6c80d2d 100644 --- a/mas_core/generator.py +++ b/mas_core/generator.py @@ -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)) diff --git a/tests/test_destination.py b/tests/test_destination.py new file mode 100644 index 0000000..bb3eb75 --- /dev/null +++ b/tests/test_destination.py @@ -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 diff --git a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md index 73dc690..882f7c1 100644 --- a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md +++ b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md @@ -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 diff --git a/workplans/MAS-WP-0006-address-composer.md b/workplans/MAS-WP-0006-address-composer.md index 7d42efe..d8d6113 100644 --- a/workplans/MAS-WP-0006-address-composer.md +++ b/workplans/MAS-WP-0006-address-composer.md @@ -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