Test MAS addresses against real Postfix; close MAS-WP-0002, add MAS-WP-0003

- experiments/postfix-probe: Postfix 3.7 -> pipe -> mas-gateway -> ticket
  service in Docker; 14 address forms x 5 configurations. 13/14 arrive
  byte-identical everywhere; quoted "ticket=PR42" is unquoted in transit.
  Legacy bang/percent rewrites never fire for MAS addresses.
- docs/experiment-review.md: Email infrastructure section with results
- MAS-WP-0003: provider-side live test on a real subdomain (needs DNS/MX)

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 623586@bnt-lap001
Assistant-Session: 10b30928-5b3b-4c11-9934-5ecca07367d8
This commit is contained in:
tegwick 2026-10-08 00:51:27 +02:00
parent 8d3878d9c8
commit e19d557a4c
13 changed files with 1179 additions and 20 deletions

View file

@ -5,11 +5,12 @@ Status of MAS-WP-0002, Milestone 9. The answers below come from building
and from running the test suite (135 tests, all offline except the
loopback fixture servers).
**Scope caveat:** every "email path" here is simulated. Raw RFC 5322
messages go through the real `mas-gateway pipe` entrypoint, but no
production MTA or mailbox provider was involved. The *Email infrastructure*
question is therefore still open and needs the live test described at the
end.
**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/`). Mailbox providers (Gmail, Outlook,
corporate gateways) and public DNS/MX were not involved. That half moves
to **MAS-WP-0003**.
## Answers to the review questions
@ -62,11 +63,43 @@ Facets have no statute declaration at all, so they are always reported as
they recognise.
### Email infrastructure: which forms were rewritten or rejected?
**Not yet tested.** Known risks to check:
- `=` overlaps with SRS (`SRS0=...`) and VERP encodings.
- `!` is UUCP bang-path syntax. Some validators and web forms reject it.
- Postfix `recipient_delimiter = +` strips the facet for lookups. That fits
the facet-degradation design.
**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.
**Still untested (MAS-WP-0003):**
- whether senders' providers and web forms accept `!` and `=` in recipient
addresses;
- overlap with SRS (`SRS0=...`) and VERP when forwarding;
- facet stripping by providers.
### Resource addressing: did it improve correlation?
Yes. The service replies *from* `ticket=GS-…@great-service.example`, so an
@ -107,10 +140,10 @@ 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.
## Recommended next step (before the decision gate)
Run the live path: a real domain, the static statute served at
`/.well-known/mas`, Postfix with the `mas-gateway pipe` transport, and test
mail sent from Gmail, Outlook and one corporate gateway to
`ticket!callback@`, `ticket=ID@` and `ticket=ID+comments@`. Record which
addresses arrive unchanged. That evidence answers the open
*Email infrastructure* question.
## Next step (MAS-WP-0003, before the decision gate)
Run the provider half on a real subdomain:
- an MX pointing to a Postfix configured like the probe;
- the statute served at `https://<sub>/.well-known/mas`;
- test mail from Gmail, Outlook and one corporate gateway to
`ticket!callback@`, `ticket=ID@` and `ticket=ID+comments@`, plus replies
to the service's `ticket=ID@` From address.

View file

@ -0,0 +1,8 @@
# Real Postfix -> pipe transport -> mas-gateway -> reference ticket service.
FROM debian:bookworm-slim
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
postfix python3 python3-jsonschema ca-certificates && rm -rf /var/lib/apt/lists/*
RUN useradd -r -s /usr/sbin/nologin mas
COPY entrypoint.sh /entrypoint.sh
EXPOSE 25 8024 8025
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

View file

@ -0,0 +1,29 @@
# Postfix probe
A real Postfix delivers MAS addresses through the `pipe` transport to
`mas-gateway` and on to the reference ticket service. A capture proxy
(`capture.py`) records the envelope recipient each event carried, so the
probe can tell whether Postfix rewrote anything.
```bash
docker build -t mas-postfix-probe experiments/postfix-probe
python3 experiments/postfix-probe/probe.py --profile defaults # relay domain
python3 experiments/postfix-probe/probe.py --profile local-domain # domain in mydestination
python3 experiments/postfix-probe/probe.py --profile local-domain-safe # + swap_bangpath/percent_hack off
```
The other profiles are `defaults-local` and `mas-safe-local`. The repo is
mounted read-only. In `results/` a `=` mark means the address arrived
unchanged and `!` means it was rewritten. Findings are in
`docs/experiment-review.md` under *Email infrastructure*.
The Postfix side is the `master.cf` entry in `entrypoint.sh`:
```
mas unix - n n - - pipe
flags=Rq user=mas argv=/usr/local/bin/mas-gateway --statute <statute>
pipe --app-url http://127.0.0.1:8024/mas-events --recipient ${recipient}
```
Set `mas_destination_recipient_limit = 1` so each pipe invocation handles
one recipient.

View file

@ -0,0 +1,40 @@
"""Probe-only proxy: records each MAS event as delivered by Postfix, then
forwards it to the ticket service and records the app's answer."""
import json
import sys
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
UPSTREAM = sys.argv[1]
EVENTS = []
class H(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
req = urllib.request.Request(UPSTREAM, body, {"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
status, resp = r.status, r.read()
except urllib.error.HTTPError as e:
status, resp = e.code, e.read()
EVENTS.append({"event": json.loads(body), "app_status": status, "app": json.loads(resp or b"null")})
self._send(status, resp)
def do_GET(self):
self._send(200, json.dumps(EVENTS).encode())
def _send(self, status, body):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
ThreadingHTTPServer(("0.0.0.0", 8024), H).serve_forever()

View file

@ -0,0 +1,42 @@
#!/bin/sh
# Repo is mounted read-only at /mas. PROFILE=defaults|mas-safe selects Postfix settings.
set -e
cat > /usr/local/bin/mas-gateway <<'SH'
#!/bin/sh
exec env PYTHONPATH=/mas python3 -m mas_gateway.main "$@"
SH
chmod +x /usr/local/bin/mas-gateway
postconf -e "maillog_file=/dev/stdout" "myhostname=mx.probe.test" "mydestination=" \
"relay_domains=great-service.example" "transport_maps=inline:{great-service.example=mas:}" \
"mynetworks=0.0.0.0/0" "smtpd_relay_restrictions=permit_mynetworks,reject" \
"mas_destination_recipient_limit=1" "recipient_delimiter=+" "compatibility_level=3.6"
# PROFILE: defaults - Postfix defaults; probe client is "remote" (no address rewriting)
# defaults-local - defaults, but every client gets the local rewrite context
# mas-safe-local - local rewrite context with swap_bangpath/allow_percent_hack off
# local-domain - domain served via mydestination (local delivery class), defaults
# local-domain-safe - local-domain with swap_bangpath/allow_percent_hack off
case "$PROFILE" in
local-domain*) postconf -e "relay_domains=" "transport_maps=" "mydestination=great-service.example" \
"local_transport=mas" "local_recipient_maps=" ;;
esac
case "$PROFILE" in
*-local) postconf -e "local_header_rewrite_clients=static:all" ;;
esac
case "$PROFILE" in
mas-safe*|*-safe) postconf -e "swap_bangpath=no" "allow_percent_hack=no" ;;
esac
cat >> /etc/postfix/master.cf <<'CF'
mas unix - n n - - pipe
flags=Rq user=mas argv=/usr/local/bin/mas-gateway
--statute /mas/examples/statute/great-service.example/.well-known/mas
pipe --app-url http://127.0.0.1:8024/mas-events --recipient ${recipient}
CF
# Postfix runs `mas-gateway` as user mas; the service needs a writable DB.
mkdir -p /var/lib/tickets && chown mas /var/lib/tickets
su -s /bin/sh mas -c "PYTHONPATH=/mas python3 /mas/examples/ticket_service/ticket_service.py \
--db /var/lib/tickets/t.sqlite3 --listen 0.0.0.0:8025" &
python3 /mas/experiments/postfix-probe/capture.py http://127.0.0.1:8025/mas-events &
echo "PROFILE=${PROFILE:-defaults}"
postconf swap_bangpath allow_percent_hack recipient_delimiter local_header_rewrite_clients
exec postfix start-fg

View file

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Send MAS test addresses through a real Postfix and report what arrived.
python experiments/postfix-probe/probe.py --profile defaults
python experiments/postfix-probe/probe.py --profile defaults-local
python experiments/postfix-probe/probe.py --profile mas-safe-local
Starts the mas-postfix-probe container, sends one message per address via
SMTP, then compares the envelope recipient Postfix handed to mas-gateway
with the one that was sent.
"""
import argparse
import json
import smtplib
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
D = "great-service.example"
# (label, local-part as sent in RCPT TO). "{ID}" is filled with the created ticket id.
CASES = [
("P2 intake", "ticket!callback"),
("resource", "ticket={ID}"),
("resource + facet", "ticket={ID}+comments"),
("extra '=' in id", "ticket==X1"),
("registry function", "support"),
("person-style", "bernd.worsch"),
("upper-case local-part", "Ticket={ID}"),
("percent data", "ticket=PR%2B42"),
("percent hack form", "ticket%other.example"),
("bang in resource", "ticket={ID}!web"),
("facet + interaction", "ticket={ID}+comments!callback"),
("reserved $", "ticket=PR42$4.99"),
("quoted local-part", '"ticket=PR42"'),
("unknown resource", "ticket=DOES-NOT-EXIST"),
]
def run(profile, port):
name = f"mas-probe-{profile}"
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
subprocess.run(["docker", "run", "-d", "--name", name, "-e", f"PROFILE={profile}",
"-v", f"{ROOT}:/mas:ro", "-p", f"127.0.0.1:{port}:25",
"-p", f"127.0.0.1:{port + 1}:8024", "mas-postfix-probe"], check=True,
capture_output=True)
try:
for _ in range(60):
try:
smtplib.SMTP("127.0.0.1", port, timeout=2).quit()
break
except OSError:
time.sleep(0.5)
results, tid = [], None
for label, lp in CASES:
rcpt_lp = lp.replace("{ID}", tid or "MISSING")
rcpt = f"{rcpt_lp}@{D}"
msg = (f"From: probe@example.net\r\nTo: {rcpt}\r\nSubject: {label}\r\n"
f"Message-ID: <{abs(hash(rcpt))}@example.net>\r\n\r\nprobe\r\n")
before = len(events(port))
try:
with smtplib.SMTP("127.0.0.1", port, timeout=10) as s:
refused = s.sendmail("probe@example.net", [rcpt], msg)
smtp = "accepted" if not refused else f"refused {refused}"
except smtplib.SMTPException as e:
smtp = f"rejected: {e}"
got = wait_event(port, before) if smtp == "accepted" else None
arrived = got["event"]["recipient"] if got else None
mas = got["event"]["mas"] if got else {}
app = got["app"] if got else None
if label == "P2 intake" and app:
tid = app.get("ticket_id")
results.append({"label": label, "sent": rcpt, "smtp": smtp, "arrived_as": arrived,
"unchanged": arrived == rcpt,
"mas_valid": mas.get("valid"), "kind": mas.get("kind"),
"app_status": got["app_status"] if got else None,
"app": (app or {}).get("status") if isinstance(app, dict) else app})
logs = subprocess.run(["docker", "logs", name], capture_output=True, text=True)
return results, logs.stdout + logs.stderr
finally:
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
def events(port):
with urllib.request.urlopen(f"http://127.0.0.1:{port + 1}/") as r:
return json.loads(r.read())
def wait_event(port, before, timeout=15):
end = time.time() + timeout
while time.time() < end:
ev = events(port)
if len(ev) > before:
return ev[-1]
time.sleep(0.3)
return None
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--profile", choices=["defaults", "defaults-local", "mas-safe-local", "local-domain", "local-domain-safe"], default="defaults")
ap.add_argument("--port", type=int, default=2525)
ap.add_argument("--json", metavar="FILE")
ap.add_argument("--log", metavar="FILE")
a = ap.parse_args()
results, log = run(a.profile, a.port)
for r in results:
mark = "=" if r["unchanged"] else "!"
print(f"{mark} {r['label']:<22} {r['sent']:<52} -> {r['arrived_as'] or r['smtp']}"
f" [{r['kind']}/{r['app']}]")
if a.json:
Path(a.json).write_text(json.dumps({"profile": a.profile, "results": results}, indent=2) + "\n")
if a.log:
Path(a.log).write_text(log)
sys.exit(0)

View file

@ -0,0 +1,159 @@
{
"profile": "defaults-local",
"results": [
{
"label": "P2 intake",
"sent": "ticket!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 201,
"app": "created"
},
{
"label": "resource",
"sent": "ticket=GS-9G9WEYSMDH@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-9G9WEYSMDH@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "resource + facet",
"sent": "ticket=GS-9G9WEYSMDH+comments@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-9G9WEYSMDH+comments@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "extra '=' in id",
"sent": "ticket==X1@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket==X1@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "registry function",
"sent": "support@great-service.example",
"smtp": "accepted",
"arrived_as": "support@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 404,
"app": "not-handled"
},
{
"label": "person-style",
"sent": "bernd.worsch@great-service.example",
"smtp": "accepted",
"arrived_as": "bernd.worsch@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "upper-case local-part",
"sent": "Ticket=GS-9G9WEYSMDH@great-service.example",
"smtp": "accepted",
"arrived_as": "Ticket=GS-9G9WEYSMDH@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "percent data",
"sent": "ticket=PR%2B42@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR%2B42@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "percent hack form",
"sent": "ticket%other.example@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket%other.example@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "bang in resource",
"sent": "ticket=GS-9G9WEYSMDH!web@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-9G9WEYSMDH!web@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "facet + interaction",
"sent": "ticket=GS-9G9WEYSMDH+comments!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-9G9WEYSMDH+comments!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "reserved $",
"sent": "ticket=PR42$4.99@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42$4.99@great-service.example",
"unchanged": true,
"mas_valid": false,
"kind": null,
"app_status": 422,
"app": "not-mas"
},
{
"label": "quoted local-part",
"sent": "\"ticket=PR42\"@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42@great-service.example",
"unchanged": false,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "unknown resource",
"sent": "ticket=DOES-NOT-EXIST@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=DOES-NOT-EXIST@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
}
]
}

View file

@ -0,0 +1,159 @@
{
"profile": "defaults",
"results": [
{
"label": "P2 intake",
"sent": "ticket!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 201,
"app": "created"
},
{
"label": "resource",
"sent": "ticket=GS-2H6T5NBH6K@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-2H6T5NBH6K@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "resource + facet",
"sent": "ticket=GS-2H6T5NBH6K+comments@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-2H6T5NBH6K+comments@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "extra '=' in id",
"sent": "ticket==X1@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket==X1@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "registry function",
"sent": "support@great-service.example",
"smtp": "accepted",
"arrived_as": "support@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 404,
"app": "not-handled"
},
{
"label": "person-style",
"sent": "bernd.worsch@great-service.example",
"smtp": "accepted",
"arrived_as": "bernd.worsch@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "upper-case local-part",
"sent": "Ticket=GS-2H6T5NBH6K@great-service.example",
"smtp": "accepted",
"arrived_as": "Ticket=GS-2H6T5NBH6K@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "percent data",
"sent": "ticket=PR%2B42@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR%2B42@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "percent hack form",
"sent": "ticket%other.example@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket%other.example@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "bang in resource",
"sent": "ticket=GS-2H6T5NBH6K!web@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-2H6T5NBH6K!web@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "facet + interaction",
"sent": "ticket=GS-2H6T5NBH6K+comments!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-2H6T5NBH6K+comments!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "reserved $",
"sent": "ticket=PR42$4.99@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42$4.99@great-service.example",
"unchanged": true,
"mas_valid": false,
"kind": null,
"app_status": 422,
"app": "not-mas"
},
{
"label": "quoted local-part",
"sent": "\"ticket=PR42\"@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42@great-service.example",
"unchanged": false,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "unknown resource",
"sent": "ticket=DOES-NOT-EXIST@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=DOES-NOT-EXIST@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
}
]
}

View file

@ -0,0 +1,159 @@
{
"profile": "local-domain-safe",
"results": [
{
"label": "P2 intake",
"sent": "ticket!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 201,
"app": "created"
},
{
"label": "resource",
"sent": "ticket=GS-VZZRSGXV12@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-VZZRSGXV12@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "resource + facet",
"sent": "ticket=GS-VZZRSGXV12+comments@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-VZZRSGXV12+comments@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "extra '=' in id",
"sent": "ticket==X1@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket==X1@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "registry function",
"sent": "support@great-service.example",
"smtp": "accepted",
"arrived_as": "support@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 404,
"app": "not-handled"
},
{
"label": "person-style",
"sent": "bernd.worsch@great-service.example",
"smtp": "accepted",
"arrived_as": "bernd.worsch@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "upper-case local-part",
"sent": "Ticket=GS-VZZRSGXV12@great-service.example",
"smtp": "accepted",
"arrived_as": "Ticket=GS-VZZRSGXV12@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "percent data",
"sent": "ticket=PR%2B42@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR%2B42@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "percent hack form",
"sent": "ticket%other.example@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket%other.example@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "bang in resource",
"sent": "ticket=GS-VZZRSGXV12!web@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-VZZRSGXV12!web@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "facet + interaction",
"sent": "ticket=GS-VZZRSGXV12+comments!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-VZZRSGXV12+comments!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "reserved $",
"sent": "ticket=PR42$4.99@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42$4.99@great-service.example",
"unchanged": true,
"mas_valid": false,
"kind": null,
"app_status": 422,
"app": "not-mas"
},
{
"label": "quoted local-part",
"sent": "\"ticket=PR42\"@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42@great-service.example",
"unchanged": false,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "unknown resource",
"sent": "ticket=DOES-NOT-EXIST@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=DOES-NOT-EXIST@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
}
]
}

View file

@ -0,0 +1,159 @@
{
"profile": "local-domain",
"results": [
{
"label": "P2 intake",
"sent": "ticket!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 201,
"app": "created"
},
{
"label": "resource",
"sent": "ticket=GS-D4QGSR85P2@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-D4QGSR85P2@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "resource + facet",
"sent": "ticket=GS-D4QGSR85P2+comments@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-D4QGSR85P2+comments@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "extra '=' in id",
"sent": "ticket==X1@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket==X1@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "registry function",
"sent": "support@great-service.example",
"smtp": "accepted",
"arrived_as": "support@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 404,
"app": "not-handled"
},
{
"label": "person-style",
"sent": "bernd.worsch@great-service.example",
"smtp": "accepted",
"arrived_as": "bernd.worsch@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "upper-case local-part",
"sent": "Ticket=GS-D4QGSR85P2@great-service.example",
"smtp": "accepted",
"arrived_as": "Ticket=GS-D4QGSR85P2@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "percent data",
"sent": "ticket=PR%2B42@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR%2B42@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "percent hack form",
"sent": "ticket%other.example@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket%other.example@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "bang in resource",
"sent": "ticket=GS-D4QGSR85P2!web@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-D4QGSR85P2!web@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "facet + interaction",
"sent": "ticket=GS-D4QGSR85P2+comments!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-D4QGSR85P2+comments!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "reserved $",
"sent": "ticket=PR42$4.99@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42$4.99@great-service.example",
"unchanged": true,
"mas_valid": false,
"kind": null,
"app_status": 422,
"app": "not-mas"
},
{
"label": "quoted local-part",
"sent": "\"ticket=PR42\"@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42@great-service.example",
"unchanged": false,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "unknown resource",
"sent": "ticket=DOES-NOT-EXIST@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=DOES-NOT-EXIST@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
}
]
}

View file

@ -0,0 +1,159 @@
{
"profile": "mas-safe-local",
"results": [
{
"label": "P2 intake",
"sent": "ticket!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 201,
"app": "created"
},
{
"label": "resource",
"sent": "ticket=GS-V5KFZ1PGY5@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-V5KFZ1PGY5@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "resource + facet",
"sent": "ticket=GS-V5KFZ1PGY5+comments@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-V5KFZ1PGY5+comments@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "extra '=' in id",
"sent": "ticket==X1@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket==X1@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "registry function",
"sent": "support@great-service.example",
"smtp": "accepted",
"arrived_as": "support@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "function",
"app_status": 404,
"app": "not-handled"
},
{
"label": "person-style",
"sent": "bernd.worsch@great-service.example",
"smtp": "accepted",
"arrived_as": "bernd.worsch@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "upper-case local-part",
"sent": "Ticket=GS-V5KFZ1PGY5@great-service.example",
"smtp": "accepted",
"arrived_as": "Ticket=GS-V5KFZ1PGY5@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "percent data",
"sent": "ticket=PR%2B42@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR%2B42@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "percent hack form",
"sent": "ticket%other.example@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket%other.example@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "opaque",
"app_status": 404,
"app": "not-handled"
},
{
"label": "bang in resource",
"sent": "ticket=GS-V5KFZ1PGY5!web@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-V5KFZ1PGY5!web@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "facet + interaction",
"sent": "ticket=GS-V5KFZ1PGY5+comments!callback@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=GS-V5KFZ1PGY5+comments!callback@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 200,
"app": "correlated"
},
{
"label": "reserved $",
"sent": "ticket=PR42$4.99@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42$4.99@great-service.example",
"unchanged": true,
"mas_valid": false,
"kind": null,
"app_status": 422,
"app": "not-mas"
},
{
"label": "quoted local-part",
"sent": "\"ticket=PR42\"@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=PR42@great-service.example",
"unchanged": false,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
},
{
"label": "unknown resource",
"sent": "ticket=DOES-NOT-EXIST@great-service.example",
"smtp": "accepted",
"arrived_as": "ticket=DOES-NOT-EXIST@great-service.example",
"unchanged": true,
"mas_valid": true,
"kind": "resource",
"app_status": 202,
"app": "unknown-resource"
}
]
}

View file

@ -4,7 +4,7 @@ type: workplan
title: "MAS v0.1 reference experiment"
domain: communication
repo: mail-address-semantics
status: active
status: done
owner: claude
topic_slug: coulomb-social
created: "2026-10-08"
@ -812,11 +812,11 @@ The service must handle this deliberately and without generating abusive backsca
## Milestone 9 — Experiment review
> First-pass findings: `docs/experiment-review.md`. Open item: live MTA/provider test (Email infrastructure question).
> Findings: `docs/experiment-review.md`. MTA half tested against real Postfix (`experiments/postfix-probe/`); provider half continues in MAS-WP-0003.
```task
id: MAS-WP-0002-T10
status: in_progress
status: done
priority: medium
state_hub_task_id: "31bbfb56-5541-5ace-aff7-94ed6f1df57a"
```

View file

@ -0,0 +1,94 @@
---
id: MAS-WP-0003
type: workplan
title: "MAS live provider test on a real subdomain"
domain: communication
repo: mail-address-semantics
status: ready
owner: claude
topic_slug: coulomb-social
created: "2026-10-08"
updated: "2026-10-08"
---
# MAS live provider test on a real subdomain
Follow-up to MAS-WP-0002. The MTA half of the review question *which
characters or address forms were rewritten or rejected?* was answered
against a real Postfix (`experiments/postfix-probe/`,
`docs/experiment-review.md`). This workplan answers the provider half. It
has to be done before the MAS-WP-0002 decision gate.
coulomb.social mail is hosted by IONOS, so the test needs its own
subdomain (for example `mas.coulomb.social`) with its own MX.
## Choose and provision the test subdomain
```task
id: MAS-WP-0003-T01
status: wait
priority: high
```
Needs the operator. Pick the subdomain. Add an MX (and A) record at IONOS
pointing to railiance01 (92.205.62.239). Allow inbound TCP 25 on
railiance01. Record the choice as a decision.
## Deploy Postfix + gateway + ticket service + statute
```task
id: MAS-WP-0003-T02
status: todo
priority: high
```
Deploy the probe configuration on railiance01, using `local-domain-safe`
settings, TLS and the usual relay restrictions. Serve the statute (with
the namespace set to the subdomain) at `https://<sub>/.well-known/mas`
through Traefik. Verify the deployment with `mas discover <sub>`.
Outbound replies need a relay with SPF/DKIM for the subdomain; until that
exists, keep replies in the outbox only.
## Provider send matrix
```task
id: MAS-WP-0003-T03
status: todo
priority: high
```
Needs the operator to send from their accounts. Send from Gmail (web and
mobile), Outlook.com and one corporate gateway to:
- `ticket!callback@`
- `ticket=ID@`
- `ticket=ID+comments@`
- `ticket=RE=2026-4711@`
Then reply to a service message sent from `ticket=ID@`. For each
combination, record whether the provider's compose form accepts the
address and what envelope recipient arrives.
## Forwarding and list paths
```task
id: MAS-WP-0003-T04
status: todo
priority: medium
```
Send via one forwarding service (SRS) and one mailing list (VERP). Check
whether `=` collides with SRS0/VERP encoding, and whether facets survive
or are stripped.
## Close the review
```task
id: MAS-WP-0003-T05
status: todo
priority: high
```
Fold the results into `docs/experiment-review.md`. Turn the spec gaps
(quoted equivalence, resource-type case, type-intake semantics for
`ticket!MODE`) into spec change proposals. Then take the MAS-WP-0002
decision gate.