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:
parent
eb10f54fd2
commit
c3e2f05ee5
5 changed files with 200 additions and 5 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
78
tests/test_destination.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue