mail-address-semantics/docs/experiment-review.md
tegwick c3e2f05ee5 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
2026-10-18 22:26:26 +02:00

15 KiB

MAS v0.1 experiment review (2026-10-08, provider pass 2026-10-18)

Status of MAS-WP-0002, Milestone 9. The answers below come from building mas_core, the mas CLI, mas-gateway and the reference ticket service, and from running the test suite (135 tests, all offline except the loopback fixture servers).

Scope: the MTA half of the email path was tested against a real Postfix (3.7.11, Debian bookworm, in Docker). The setup was SMTP → Postfix → pipe transport → mas-gateway → reference ticket service (experiments/postfix-probe/).

Provider pass (MAS-WP-0003, 2026-10-18): the same stack ran as a live MX for mas.coulomb.social on railiance01 (deploy/railiance/), with the statute at https://mas.coulomb.social/.well-known/mas. Real mail came from Gmail and Microsoft 365, and replies went out through Scaleway TEM. The evidence is the gateway's JSONL event log plus the Postfix log. Forwarding and mailing-list paths (T04) are still pending. Spec consequences are collected in docs/spec-change-proposals.md.

Answers to the review questions

Syntax: did the parser stay simple?

Yes. The parser is about 100 lines with no I/O. It always uses the maximal (P2) grammar and records required_profile, so ticket!callback parses without knowing anything about the namespace. All 31 spec fixtures and 27 added property cases pass.

Gaps found in the spec:

  • Resource-type case. The ABNF allows Ticket=PR42, but the statute schema only registers lower-case type names. Such an address parses, but it can never match a declared type, even when the statute says ascii-insensitive. The spec should either make resource types lower-case in the grammar, or state that the equivalence policy also applies to the type name.
  • Interaction-mode names. The value grammar allows call.back and a=b. The schema's mode-name pattern does not. Those modes parse but can never be enabled. This is harmless, but it should be documented.
  • Registry keys. The schema pattern already blocks + ! $ ^, but it still allows = and leading or trailing dots. Semantic lint is needed to reject a key like ticket=GS-1 (registry-key-resource).

Discovery: was exact-host metadata sufficient?

For this experiment, yes. Implementation choices the spec does not settle:

  • application/json (or +json) is required as the content type (InvalidContentType). The spec only asks for 2xx plus the schema. It should say whether the content type is checked.
  • Redirects are followed only when scheme, host and port all match. That is stricter than "same host". A port change counts as CrossHostRedirect and an https→http change as SchemeDowngrade.
  • mas_version is checked before the schema runs, so a future version is reported as UnsupportedVersion and not as a schema error.
  • Operational finding: a service must not depend on discovering its own statute. If the statute endpoint is down, the ticket service sees namespace_confirmed = false and correctly refuses to act. The mail is then declined. Operators should pin their own statute in local configuration (mas-gateway --statute). Discovery is for third-party namespaces.

Policy: could all inference stay explicit?

Yes. Policy is a pure function. Without a statute, kind is always opaque, = alone gives no resource semantics, and ! stays "unconfirmed". mas explain lists each of these as an unknown. Folding is applied only when a statute declares it, and dot folding never touches resource identifiers.

Facets have no statute declaration at all, so they are always reported as "application-defined". A later version could let statutes list the facets they recognise.

Email infrastructure: which forms were rewritten or rejected?

Postfix (tested): 14 address forms were sent through 5 Postfix configurations (experiments/postfix-probe/results/):

  • relay domain with defaults;
  • local rewrite context, with and without swap_bangpath / allow_percent_hack;
  • domain in mydestination (local delivery class), with and without those two settings.

The results:

  • 13 of 14 forms arrived byte-identical in every configuration. That includes ticket!callback, ticket=ID+comments, ticket==X1, ticket=PR%2B42, ticket=ID+comments!callback and ticket=PR42$4.99. recipient_delimiter = + does not change the envelope recipient that pipe passes on as ${recipient}. Upper case is preserved.
  • The quoted local-part "ticket=PR42" arrived unquoted as ticket=PR42. Postfix drops quotes that are not needed (RFC 5321 treats the two forms as equivalent), so a form the spec calls non-canonical became canonical in transit. Spec gap: §4 should say that a quoted local-part whose content is a valid dot-string is equivalent to the unquoted form, because an MAS receiver after an MTA cannot tell them apart.
  • The ! and % legacy rewrites did not trigger for any MAS address, even with Postfix defaults and the domain in mydestination. In 3.7 the bang-path swap fired only for a bare site!user with no @domain (checked via local sendmail: it was rerouted to site). MAS addresses always carry an @domain. Setting swap_bangpath = no and allow_percent_hack = no is still a sensible hardening default for MAS hosts.
  • $ passed through SMTP untouched. Rejecting it is purely a MAS parser decision. The gateway forwarded it as valid: false, and the app ignored it.

Providers (tested, MAS-WP-0003): four address forms were sent from four sender setups:

  • forms: ticket!callback@, ticket=ID@, ticket=ID+comments@, ticket=RE=2026-4711@;
  • sender setups: Gmail web, the Gmail mobile app, and a Microsoft 365 tenant via both Outlook mobile and Outlook desktop.
Sender Compose form Envelope recipient and To header Transport
Gmail web accepted all 4 unquoted byte-identical 209.85.x, STARTTLS, BDAT
Gmail mobile accepted all 4 unquoted byte-identical same
M365, Outlook mobile accepted all 4 unquoted byte-identical EOP 40.107.149.x
M365, Outlook desktop accepted all 4 unquoted byte-identical EOP 52.101.171.x
  • Nothing was rewritten, quoted, rejected or stripped. That covers !, a doubled = and the +comments facet. The gateway classified every message as the probe predicted: P2 function with callback → 201 created; P1 resource → 202; the facet was recognised.
  • The reply direction also holds. The service replied From ticket=GS-85QJ8WC09P@mas.coulomb.social through Scaleway TEM. Gmail showed the = From address as sent, with SPF, DKIM (d=mas.coulomb.social) and DMARC all passing. A plain Gmail "Reply" went back to that address byte-identical, with correct In-Reply-To and References, and was correlated to the ticket (200).
  • Not covered: consumer Outlook.com (the operator has no account; it uses the same Exchange Online path as M365), the Outlook-on-the-web compose form, and other providers such as Apple, Yahoo and GMX. Two providers is evidence that ! and = work in practice; it is not a survey.

Operational findings from the live run:

  • Stock Postfix kills P2. smtpd_relay_restrictions must contain a reject-class entry, or smtpd refuses to start. Also, unless allow_untrusted_routing = yes, Postfix ignores access-map results for local-parts containing ! or % (it treats them as sender-specified routing). reject_unauth_destination then refuses every P2 address. The working MAS host config relays only @DOMAIN through an explicit regexp, turns off swap_bangpath and allow_percent_hack, and ends the list in reject. A host that "just enables MAS" on a default Postfix will silently lose all ! mail.
  • A TEM "blackhole" MX swallowed inbound mail. Scaleway TEM's domain setup suggests MX 10 blackhole.tem.scaleway.com. Added next to the real MX at the same priority, it sent about half of all inbound mail to a discard host, and nobody got a bounce. Sending providers' setup wizards are a deliverability risk for MAS subdomains that receive mail.
  • Outbound port 25 was blocked at the hosting provider (and on a second host). Replies need an authenticated submission relay.

Forwarding (T04, IONOS mail forward, 2026-10-18): this is the first real failure in the run.

  • The IONOS forwarding form refuses = in the destination address. + is accepted. A resource address cannot be configured as a forward target at all.
  • The form also forces lower case. It only accepts lower-case destinations and "corrects" input. The operator entered ticket--GS-85QJ8WC09P+comments@ (with -- standing in for =), and ticket--gs-85qj8wc09p+comments@ arrived. Under the spec's default exact comparison, a lower-cased identifier no longer matches its resource, even if the = had been allowed.
  • SRS does not collide with =. IONOS rewrote the envelope sender to SRS0=ppc5=II=gmail.com=bernd.worsch@srs2.kundenserver.de. SRS never touches the recipient, so = in recipients is safe from it.
  • The facet +comments survived forwarding.
  • The header To: showed the forwarding address (mas-fwd@coulomb.social), so MAS receivers must classify the envelope recipient, as mas-gateway does.
  • Result: the gateway classified the mail as an opaque target with a facet. The ticket service correctly declined it (404, not handled), because nothing in the statute gives ticket--… resource meaning. See SCP-6 (declared alternate separator) and SCP-7 (identifier case).

Mailing list (T04, Google Groups, 2026-10-18):

  • The member form accepted = and capitals: ticket=GS-85QJ8WC09P+comments@ was accepted. The invitation was delivered to it byte-identical (Auto-Submitted: auto-generated) and was correlated to the ticket.
  • After acceptance, Google stored the member in lower case. The list post arrived for ticket=gs-85qj8wc09p+comments@. The envelope sender was the per-message bounce address coulomb-social+bncBDG…@googlegroups.com, which tags the list's own local-part and does not touch ours, so there is no collision with = or +.
  • With exact comparison the post did not match the ticket. The service quarantined it as an unknown resource and did not reply (Precedence: list). That is correct under v0.1, but the post was lost to the ticket.

T04 conclusion:

  • SRS and list bounce addresses never collide with MAS syntax, because they rewrite or tag the sender.
  • Facets survived every path.
  • The real risks are provider forms: IONOS refuses =, and two of two forwarding/list providers (IONOS, Google Groups) lower-case the recipient. Case-sensitive resource identifiers do not survive forwarding or lists, which makes SCP-7 the most consequential proposal.

Resource addressing: did it improve correlation?

Yes. The service replies from ticket=GS-…@great-service.example, so an ordinary reply lands on the resource address. Correlation uses the address only. The tests send an unrelated subject and a facet-stripped address, and both correlate.

Subject lines: could identifiers disappear?

Yes. Replies use the customer's own subject, and no ticket id is ever put in it (asserted in the tests).

Lifecycle: did closed resources stay understandable?

Yes. A closed ticket stays addressable. Mail to it is archived separately and the ticket is not changed. A single notice goes only to the original requester, and never to automated senders (Auto-Submitted, Precedence). Mail to an unknown resource is quarantined with no reply. For an MTA pipe, the gateway exits 0 when the app declines and 75 when the app is unreachable, so it never generates a bounce (no backscatter).

Interaction: was !callback useful?

Partly. It expresses intent cleanly, but the service still needs a phone number and a time. The address cannot carry those, so the first reply has to ask by email. !callback therefore starts a negotiation; it does not replace one.

The bare ticket in ticket!callback is an opaque target. It has no defined link to the resource type ticket. The spec's canonical example (§7) implies such a link. The statute has to register ticket as an intake function, and the service has to know what that means. The spec should either say this explicitly, or define "type intake" semantics for an opaque target that names a declared resource type.

Security: did any address start acting like a capability?

(Addendum from the live run: an intake address such as ticket!callback@ that auto-replies turns any forged From: into outbound mail to a third party. That is a backscatter/reflection vector at the application layer. The live deployment therefore caps all replies sent (300 in total, T06) and discards Postfix's own bounces. See SCP-5.)

Not for reading or control. Knowing a ticket address grants no read access and no admin action. It does let anyone append correspondence to an open ticket, which is the intended function of a communication endpoint. That is also an injection and spam vector. Ticket ids are non-guessable (10 Crockford base32 characters), but sender policy belongs to the application, not to MAS.

Decision gate (MAS-WP-0002)

Decided 2026-10-18 (founder): the recommendation below, plus the address composer pulled forward. Follow-up work: MAS-WP-0004 (Postfix profile), MAS-WP-0005 (spec errata and v0.2 drafts), MAS-WP-0006 (composer). MAS-WP-0007 retires the live test environment later.

Candidate Demonstrated need? Recommendation
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 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
P3 economic profile Not exercised Defer

T04 confirmed the table: SRS and list bounce addresses never collided with MAS syntax, and the risks it found (forms refusing =, lower-casing) are covered by SCP-6/SCP-7 and the composer.