MAS-WP-0005-T02: SCP-1 quoted local-parts equivalent on receipt

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 18:16:59 +02:00
parent 5b57a9941e
commit 0a2d811444
9 changed files with 70 additions and 6 deletions

View file

@ -1,5 +1,11 @@
# MAS specification changelog
- **SCP-1, quoted equivalence.** A quoted local-part whose content is a
valid canonical MAS local-part (no quoted-pairs) is equivalent to the
unquoted form. Receivers treat them identically; generators emit the
unquoted form; canonical parsers still reject the quoted form (§4).
Fixture p1-040 note updated.
## v0.1.1 (errata, in progress)
Errata from the reference experiment and the live provider test

View file

@ -72,6 +72,13 @@ A MAS-aware implementation MAY transport such non-canonical email addresses
as ordinary email, but MUST NOT claim that they are canonical MAS v0.1
addresses.
A quoted local-part whose content, after removing the quotes and with no
quoted-pairs, is a valid canonical MAS local-part is equivalent to that
unquoted form (RFC 5321 treats unnecessary quoting as equivalent, and MTAs
remove such quotes in transit). Receivers MUST treat the two identically.
Generators MUST emit the unquoted form. Parsers that check canonical syntax
still reject the quoted form.
### 4.1 Target
A target is non-empty.

View file

@ -303,7 +303,8 @@
"local_part": "\"ticket=PR42\"",
"profile": "p1",
"valid": false,
"error": "quoted local-part outside canonical MAS v0.1"
"error": "quoted local-part outside canonical MAS v0.1",
"note": "v0.1.1 (SCP-1): not canonical, but a receiver treats it as equivalent to ticket=PR42"
},
{
"id": "p1-041",

View file

@ -303,7 +303,8 @@
"local_part": "\"ticket=PR42\"",
"profile": "p1",
"valid": false,
"error": "quoted local-part outside canonical MAS v0.1"
"error": "quoted local-part outside canonical MAS v0.1",
"note": "v0.1.1 (SCP-1): not canonical, but a receiver treats it as equivalent to ticket=PR42"
},
{
"id": "p1-041",

View file

@ -141,6 +141,25 @@ def parse_address(value: str) -> ParsedAddress:
return ParsedAddress(local=parse_local_part(local), domain=domain)
def unquote_local_part(local: str) -> str:
"""Drop quotes a receiver cannot distinguish (v0.1.1, SCP-1).
`"ticket=PR42"` is not canonical, but RFC 5321 treats it as equivalent to
`ticket=PR42` and MTAs strip such quotes, so receivers must too. Quoted
content with quoted-pairs or inner quotes is left alone and stays
non-canonical.
"""
if len(local) >= 2 and local[0] == local[-1] == '"' and not any(c in '"\\' for c in local[1:-1]):
return local[1:-1]
return local
def parse_received_address(value: str) -> ParsedAddress:
"""Parse a recipient as a receiver sees it: unnecessary quotes removed."""
local, domain = split_address(value)
return ParsedAddress(local=parse_local_part(unquote_local_part(local)), domain=domain)
@dataclass(frozen=True)
class ValidationResult:
ok: bool

View file

@ -22,7 +22,7 @@ from pathlib import Path
from typing import Callable, Optional
from mas_core.discovery import Discovered, DiscoveryCache, DiscoveryResult, discover
from mas_core.parser import MasSyntaxError, parse_address
from mas_core.parser import MasSyntaxError, parse_received_address
from mas_core.policy import resolve
from mas_core.statute import build_statute
@ -80,7 +80,7 @@ class Resolver:
def build_event(recipient: str, message: dict, resolver: Resolver) -> dict:
try:
addr = parse_address(recipient)
addr = parse_received_address(recipient)
except MasSyntaxError as e:
mas = {"valid": False, "error": e.code, "detail": str(e)}
return {"mas_event_version": EVENT_VERSION, "recipient": recipient, "mas": mas, "message": message}

View file

@ -130,8 +130,12 @@ def test_unconfirmed_namespace_is_not_acted_on(env):
def test_non_mas_recipient_still_forwarded(env):
_, _, _, resolver = env
ev = build_event(f'"quoted"@{D}', {}, resolver)
assert ev["mas"]["valid"] is False and ev["recipient"] == f'"quoted"@{D}'
ev = build_event(f'"not mas"@{D}', {}, resolver)
assert ev["mas"]["valid"] is False and ev["recipient"] == f'"not mas"@{D}'
# SCP-1: unnecessary quotes are equivalent to the unquoted form
ev = build_event(f'"ticket=GS-1"@{D}', {}, resolver)
assert ev["mas"]["valid"] is True and ev["mas"]["equivalence_key"] == "ticket=GS-1"
assert ev["recipient"] == f'"ticket=GS-1"@{D}' # the event keeps what arrived
def test_pipe_transport_subprocess(env):

View file

@ -12,6 +12,7 @@ from mas_core.parser import (
MasSyntaxError,
parse_address,
parse_local_part,
parse_received_address,
validate_profile,
)
@ -111,3 +112,23 @@ def test_generator_rules():
p = parse_local_part(f["local_part"])
with pytest.raises(MasSyntaxError):
generate_local_part(str(p.target), facet=p.facet.value, interaction=p.interaction.mode)
@pytest.mark.parametrize("raw, expected", [
('"ticket=PR42"@example.com', "ticket=PR42"), # SCP-1: unnecessary quotes
('"ticket!callback"@example.com', "ticket!callback"),
("ticket=PR42@example.com", "ticket=PR42"),
])
def test_received_quoted_local_part_is_equivalent(raw, expected):
assert parse_received_address(raw).local.raw == expected
@pytest.mark.parametrize("raw", ['"a\\"b"@example.com', '"a b"@example.com', '"x@y"@example.com'])
def test_received_quoted_non_mas_stays_invalid(raw):
with pytest.raises(MasSyntaxError):
parse_received_address(raw)
def test_canonical_parser_still_rejects_quotes():
with pytest.raises(MasSyntaxError):
parse_address('"ticket=PR42"@example.com')

View file

@ -97,6 +97,11 @@ its own task.
141 tests pass, and the reference parser agrees with all 35 bundle
fixtures. SCP-2 text corrected: `Ticket=PR42` becomes non-canonical,
not opaque.
- 2026-10-18 SCP-1 done: §4 text; `mas_core.parser.unquote_local_part` and
`parse_received_address` (receiver side; the canonical parser stays
strict); the gateway now parses with them; fixture p1-040 note and
CHANGELOG. The end-to-end test now uses a real non-MAS recipient,
because `"quoted"@` is equivalent to `quoted@`. 148 tests pass.
## Reference service: case-robust ticket ids