mail-address-semantics/workplans/MAS-WP-0005-spec-errata-v0-1-1.md
tegwick 2123e2e4c9 MAS-WP-0005 finished; residual MAS-WP-0008 (finalize v0.2)
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:47:04 +02:00

462 lines
20 KiB
Markdown

---
id: MAS-WP-0005
type: workplan
title: "MAS spec v0.1.1 errata and v0.2 proposals"
domain: communication
repo: mail-address-semantics
status: finished
owner: claude
topic_slug: coulomb-social
created: "2026-10-18"
updated: "2026-10-18"
origin: residual
origin_ref: MAS-WP-0003
state_hub_workstream_id: "e9ae68c7-b960-51c0-b697-f05278384a82"
---
# MAS spec v0.1.1 errata and v0.2 proposals
Decision-gate outcome of MAS-WP-0002 (founder, 2026-10-18). Turn
`docs/spec-change-proposals.md` into spec text. The `mas-v0.1-spec/` bundle
is normative, and `mas_conformance/` plus `mas_core/schema/` are vendored
copies checked for drift, so each change updates the bundle, the vendored
copies, the fixtures and the implementation together.
## Review the proposals with the founder
```task
id: MAS-WP-0005-T01
status: done
priority: high
state_hub_task_id: "10c0433a-9b25-5b62-987f-2be7e9d49df2"
```
Walk through SCP-1 to SCP-7, settle the open options (SCP-2 (a) or (b),
SCP-6 split rules), and record which proposals are accepted.
- 2026-10-18: SCP-2 decided as option (a): resource types are lower-case
in the grammar. SCP-6 is under discussion. Founder direction: `=` stays
canonical and `--` is accepted as a replacement only for endpoints that
refuse `=`. A transform profile per recipient/destination may be needed
when something we send out must match that destination's requirements.
- 2026-10-18: SCP-6 decided. The statute opts in (option A) to
`alternate_separator`. The split is at the first `--` after a declared
type, and type names may not contain `--`. The sending side gets a
normative "destination transform" mechanism (`separator`, `lowercase`)
plus an informative integration section with IONOS as the example.
Concrete destination profiles live in implementation config, not the
spec. Still open: transforms beyond separator and case, to be added
only on evidence.
- 2026-10-18: SCP-6 extended at the founder's request to cover similar
cases. `alternate_syntax` holds `resource: "--"` and `interaction: "__"`
(split at the last `__`, only when the suffix is an enabled mode). There
is no stand-in for `+`. The closed transform set is resource-alternate,
interaction-alternate, lowercase, drop-facet and drop-interaction.
Quoting, dots and length need no transform.
- 2026-10-18: revised by the founder. Stand-ins use dashes only: `---`
replaces `__` for `!`, and no other stand-in characters are added. `+` is
functional by default with no stand-in. The stand-ins are now fixed by
the spec; the statute only enables them
(`alternate_syntax: {resource: true, interaction: true}`). Matching
order: interaction (`---MODE` at the end, enabled mode) first, then
resource (`TYPE--ID`). IDs must not contain `---` when the interaction
stand-in is enabled.
- 2026-10-18: SCP-1, SCP-3, SCP-5 and SCP-7 confirmed as written. All seven
proposals are accepted, so T01 is done. T02 implements the v0.1.1
errata (SCP-1, SCP-2 (a), SCP-3, SCP-5, and the SCP-7 recommendation);
T04 implements SCP-6 and the SCP-7 statute field for v0.2.
## v0.1.1 errata
```task
id: MAS-WP-0005-T02
status: done
priority: high
state_hub_task_id: "eb4c83d0-5b2c-5b7c-a6e3-57b85fe78334"
```
Apply the accepted errata: SCP-1 (quoted equivalence), SCP-2 (lower-case
types), SCP-3 (type intake, `intake_for`), SCP-5 (intake auto-reply
bounds), and the SCP-7 recommendation (case-robust identifiers). Add
fixtures and update the parser, policy and schema.
Decisions (founder, 2026-10-18): keep the folder `mas-v0.1-spec/` and mark
the revision v0.1.1 inside it (SPECIFICATION §1, README, new CHANGELOG).
For SCP-5, write the spec text now and track the DMARC-gated reply code as
its own task.
- 2026-10-18 SCP-2 done:
- grammar `resource-type = type-word *(("-" / "_") type-word)`;
- statute schema key pattern `^[a-z0-9]+([_-][a-z0-9]+)*$` (bundle and
vendored copy);
- `mas_core.parser.is_resource_type`, also used by statute lint;
- the bundle's reference parser;
- §6.1 text and the CHANGELOG;
- fixtures p1-042 to p1-045, and prop-case-001 flipped to invalid.
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.
- 2026-10-18 SCP-3 done:
- §7 text and the CHANGELOG;
- statute schema `addresses.*.intake_for`, with the resource-type
pattern (bundle and vendored copy);
- lint `intake-not-function` (error) and `intake-unknown-type` (error,
or warning when the statute inherits);
- the policy reports `intake_for`, and it reaches gateway events;
- the ticket service creates tickets only for
`intake_for == "ticket"`, not for a hard-coded target name;
- example statutes (great-service, mas.coulomb.social) and the
conformance statute declare the intake.
151 tests pass. The live statute needs a redeploy to carry
`intake_for`; until then the new ticket service would not recognise
the intake.
- 2026-10-18 SCP-5 (§14 item 10) and SCP-7 (§6.2 recommendation) spec text
done. The reference service already meets the SCP-5 MUSTs (cap, no
replies to automated mail); the SHOULD (authenticated sender) is T05.
CHANGELOG marked v0.1.1. T02 done.
## Reference service: case-robust ticket ids
```task
id: MAS-WP-0005-T03
status: done
priority: high
state_hub_task_id: "7c19e951-7ab4-580c-9e4c-9fead9ee946e"
```
SCP-7 evidence: IONOS and Google Groups both lower-cased the recipient,
and a list post was lost to its ticket. Make the reference ticket service
issue and match ids case-insensitively. Crockford base32 is case-insensitive
by design. Verify on the live MX by repeating the Google Groups post.
- 2026-10-18: implemented. Ticket lookup uses `COLLATE NOCASE` (also in the
`ticket()`/`close()` admin calls). Once found, the service stores,
replies and reports under the id as issued, so replies keep coming From
`ticket=GS-…@`. Test `test_lower_cased_id_correlates` passes, as do all
137 tests. Deployed as `mas-live:wp0005-20261018`. A dry check on a copy
of the live DB resolves `gs-85qj8wc09p` to `GS-85QJ8WC09P`.
- 2026-10-18 15:38Z: verified live. A Google Groups post arrived for
`ticket=gs-85qj8wc09p+comments@` and was **correlated** (200) to
GS-85QJ8WC09P with facet `comments`. The identical post at 12:05Z had
been quarantined. No reply was sent (Precedence: list); still 1/300
sent.
## v0.2 drafts: SCP-6 and the SCP-7 statute field
```task
id: MAS-WP-0005-T04
status: done
priority: medium
state_hub_task_id: "4ff67908-52c4-5106-b851-e1dec8274226"
```
Draft the statute object `alternate_syntax` (enables the fixed stand-ins
`--` for `=` and `---` for `!`),
the destination transforms (see SCP-6), and
`equivalence.resource_id_case`, with policy semantics and fixtures.
Split into T07 to T10 on 2026-10-18 (token budget). Stand-in recognition
lives in the policy layer, not the parser: the parser stays statute-blind,
and only a statute that enables the stand-ins gives `--`/`---` meaning.
v0.2 is a separate draft bundle `mas-v0.2-spec/`, because it adds features
rather than errata.
## SCP-5: reply only to authenticated senders
```task
id: MAS-WP-0005-T05
status: done
priority: medium
state_hub_task_id: "8d3bc09e-6f8d-5125-b10c-45b9aa5643a2"
```
Implement the SHOULD part of spec §14 item 10 in the reference stack:
- Postfix adds `Authentication-Results` (e.g. via OpenDMARC or rspamd
milter) on the live MX;
- `mas-gateway` passes the DMARC verdict into the event (event schema
addition);
- the ticket service sends intake auto-replies only on DMARC pass and
records unauthenticated requests without replying.
Decided 2026-10-18 to keep this out of the v0.1.1 errata code.
Split 2026-10-18 into T11 to T13. Authentication runs in the MTA
(OpenDKIM + OpenDMARC milters), because SPF, DKIM and DMARC need DNS and
only `mas_core/discovery.py` may touch the network. The gateway only
reads the `Authentication-Results` header carrying our own authserv-id,
and the MTA strips incoming headers that claim it.
## Redeploy the live MX with v0.1.1
```task
id: MAS-WP-0005-T06
status: done
priority: medium
state_hub_task_id: "f9bbfc03-e611-573f-8017-91cd18472ba9"
```
Build and deploy an image with the v0.1.1 parser, the `intake_for`
statute and the case-insensitive ticket service. Then re-run a smoke test
(intake create, resource correlate, lower-case correlate).
- 2026-10-18: before the deploy, the ticket service was changed to issue
lower-case ids (`gs-` + lower-case Crockford base32), at the founder's
request after a reply showed `ticket=GS-6KDSB1BA11@`. Existing upper-case
tickets still match. Deployed `mas-live:v0.1.1-f78939a`. The live statute
serves `intake_for: "ticket"`, `mas discover` is valid, `--max-sent 300`
and the TEM relay are intact, and EHLO answers 250. `mas explain` now
shows `intake_for`.
- 2026-10-18 smoke test (founder, Gmail):
- `ticket!callback@` (16:34Z) created ticket `gs-8b72wz9tye` via
`intake_for`, and the reply was sent through TEM;
- the answer to `ticket=gs-8b72wz9tye@` was correlated (In-Reply-To
set);
- `ticket=GS-8B72WZ9TYE@` (20:14Z) arrived upper-case, because Gmail does
not fold, and was correlated to the same ticket;
- two typo sends to `ticket=GW-8B72WZ9TYE@` (Gmail autocomplete) were
quarantined with no reply.
Sends: 3/300. Done.
## v0.2 draft bundle and version support
```task
id: MAS-WP-0005-T07
status: done
priority: medium
state_hub_task_id: "908688e6-559b-5d44-a8bc-284e3bbc9366"
```
Create `mas-v0.2-spec/` (draft): the specification with SCP-6
(`alternate_syntax`, matching order, destination transforms, informative
integration section) and the SCP-7 statute field
`equivalence.resource_id_case`, the v0.2 statute schema, and a CHANGELOG.
`mas_core` accepts statutes with `mas_version` 0.1 and 0.2, each validated
against its own schema. Discovery accepts both.
- 2026-10-18 done:
- `mas-v0.2-spec/` draft: §6.3 stand-ins with the matching order, §8
`resource_id_case`, §11 fields, §12.1 destination transforms, §12.2
informative integrations (IONOS, Google Groups), and §13 length after
transforms;
- schema v0.2 (`mas_version` 0.2, `alternate_syntax`,
`equivalence.resource_id_case`), an example statute and the CHANGELOG.
The grammar is unchanged; stand-ins are not canonical syntax.
- `mas_core`: one schema validator per version (`SUPPORTED_VERSIONS`);
`Statute` carries `mas_version`, `alt_resource`, `alt_interaction`
and `resource_id_case`; inheritance uses `dataclasses.replace`;
discovery accepts 0.1 and 0.2.
- Tests that used 0.2 as the "unsupported" example now use 0.3. 155
tests pass.
- The draft keeps 25 "v0.1" mentions for rules that are unchanged; align
the wording when v0.2 is finalized.
## v0.2 receiving side: stand-ins and resource_id_case in policy
```task
id: MAS-WP-0005-T08
status: done
priority: medium
state_hub_task_id: "a345e073-04ca-54e1-bac7-837fe7955e4e"
```
The policy interprets `---MODE` (interaction first, enabled mode only) and
`TYPE--ID` (declared type only) when the statute enables them, and applies
`resource_id_case: insensitive` in the equivalence key. Statute lint covers
the stand-in constraints. Statute-aware fixtures.
- 2026-10-18 done:
- `mas_core.policy.apply_stand_ins` runs first in `resolve()`. It
matches the interaction (`---MODE`, enabled mode, last `---`; in the
facet or the target) and then the resource (`TYPE--ID`, declared type,
first `--`). Everything later (classification, registry,
`intake_for`, equivalence) sees the canonical parse, and the parser
stays statute-blind.
- `AddressSemantics` gains `stand_ins` and `canonical_local_part`; the
equivalence key lower-cases resource ids under
`resource_id_case: insensitive`.
- The gateway event reports the canonical target and base, plus
`target_syntax: resource-alternate` when `--` was used, while
`local_part` keeps what arrived.
- Lint: `stand-in-collision` (registry keys that would change meaning),
`stand-in-mode-name`, and `stand-in-without-p2` (warning).
- Tests: the spec §6.3 example table, correlation of
`ticket--GS-…+comments` with `ticket=gs-…`, intake via `---`, the
switch requirement (v0.1, off, no statute), exact id case, and lint.
168 tests pass.
- A v0.2 statute for mas.coulomb.social lints clean, and the gateway
turns `ticket--gs-85qj8wc09p+comments@` into resource
`ticket=gs-85qj8wc09p` with facet `comments`.
## v0.2 sending side: destination transforms in the generator
```task
id: MAS-WP-0005-T09
status: done
priority: medium
state_hub_task_id: "853d1b76-8b7f-5234-aacc-dc1fb5d155c2"
```
Add a generator `destination` parameter with the five transforms. Emit the
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
id: MAS-WP-0005-T10
status: done
priority: medium
state_hub_task_id: "6431ae1e-c449-5732-a40f-99b0644da44a"
```
Switch `mas.coulomb.social` to a v0.2 statute that enables both stand-ins
and case-insensitive ids. Repeat the IONOS forward test with
`ticket--gs-…+comments@`. It must now be correlated to the ticket.
- 2026-10-18 done. The live statute is v0.2 (`alternate_syntax`
resource+interaction, `resource_id_case: insensitive`), deployed as image
`mas-live:v0.2-51bf87c`. The operator sent to `mas-fwd@coulomb.social`
(IONOS forward, unchanged since MAS-WP-0003). It arrived at 20:29:57Z
for `ticket--gs-85qj8wc09p+comments@` (envelope sender SRS0 from IONOS)
and was read as `ticket=gs-85qj8wc09p+comments`
(`target_syntax: resource-alternate`). It was **correlated** (200) to
GS-85QJ8WC09P with facet `comments`; in MAS-WP-0003 the same forward was
404 not-handled. The ticket now holds mail via all four paths: direct,
reply, Google Groups (lower-cased) and IONOS forward (stand-in). T04 is
complete. `mas explain` also shows "read as … (v0.2 stand-in)".
## MTA authentication: OpenDKIM + OpenDMARC milters
```task
id: MAS-WP-0005-T11
status: done
priority: medium
state_hub_task_id: "5c4eddfd-a371-59d2-a4f1-3915a675f639"
```
In the live image, OpenDKIM (verify only) and OpenDMARC (SPF
self-validation) run as Postfix smtpd milters. Both add
`Authentication-Results: mas.coulomb.social; …`. Incoming A-R headers
claiming that authserv-id are removed (`RemoveARFrom`). Annotate only;
never reject on DMARC failure. Milter failure means accept
(`milter_default_action = accept`).
- 2026-10-18 done (verified in a local container; live in T13):
- Image packages: opendkim, opendmarc, publicsuffix. OpenDKIM runs in
verify mode; OpenDMARC with `SPFSelfValidate`, `RejectFailures false`
and `TrustedAuthservIDs` set to the domain.
- **Forged A-R stripping is done by Postfix, not OpenDKIM.**
`RemoveARFrom` and `RemoveARAll` did not remove forged
`Authentication-Results: mas.coulomb.social; dmarc=pass` headers. A
`header_checks` regexp IGNORE rule does, and it leaves the milters'
own headers alone, because header_checks do not apply to
milter-added headers. Verified for a forged pass, an upper-case
variant and a foreign id (foreign ids are kept; the gateway ignores
them).
- Daemonized, OpenDKIM died on its first connection ("can't read
SMFIC_OPTNEG reply"), so both milters now run with `-f` in the
background.
- Result for a spoofed `From: test@coulomb.social` sent directly:
`spf=fail`, `dmarc=fail (p=none)`. Our A-R headers sit above our
`Received:` line; anything forged sits below it.
- Note for MAS-WP-0004: use header_checks for A-R stripping and
foreground milters.
## Gateway verdict and reply gating
```task
id: MAS-WP-0005-T12
status: done
priority: medium
state_hub_task_id: "5a3b7356-37c0-5292-8936-492946c6b211"
```
`mas-gateway` takes `--trusted-authserv-id`. It parses only A-R headers
from that id and puts `message.authentication = {dmarc, spf, dkim,
header_from}` into the event. The ticket service gets
`--require-dmarc-pass`: it still creates tickets and attaches mail, but
sends auto-replies (intake confirmation, closed notice) only when
`dmarc == pass`. Unit tests cover forged A-R headers from other ids.
- 2026-10-18 done:
- `mas_gateway.core.authentication_from_headers` reads only A-R headers
with the trusted authserv-id **above the first Received header**
(RFC 8601 §5). Forged copies below our hop are ignored even if the
MTA did not strip them. Several DKIM results count as pass if any one
passes.
- The event gets `message.authentication = {dmarc, spf, dkim,
header_from}` or null. `--trusted-authserv-id` works on both pipe and
webhook; a JSON webhook passes the provider's verdict through.
- Ticket service `--require-dmarc-pass`: tickets are still created and
mail attached, but replies go out only on `dmarc == pass`.
- Entrypoint: gateway `--trusted-authserv-id $DOMAIN`, ticket service
`--require-dmarc-pass`.
- Tests: own header read; forged (below Received) and foreign ids
ignored; no trusted id gives no verdict; reply gated off without a
verdict and sent on pass. 183 tests pass.
## Live check: authenticated vs forged sender
```task
id: MAS-WP-0005-T13
status: done
priority: medium
state_hub_task_id: "8a1f1fc4-f300-5e60-8714-5a47c39d88a1"
```
Deploy. A Gmail intake (DMARC pass) gets a reply. A mail with a
`From:` in the operator's own domain sent directly from the workstation
(SPF/DKIM fail, so DMARC fail) creates a ticket but gets no reply. Check
the send counter.
- 2026-10-18 done (image `mas-live:v0.2-a45f1a1`; both milters listening,
gateway `--trusted-authserv-id`, ticket service `--require-dmarc-pass`):
- **Forged:** sent from the workstation with `From: test@coulomb.social`
and a forged `Authentication-Results: mas.coulomb.social; dmarc=pass`.
The event verdict was `dmarc: fail, spf: fail`: the forgery was
stripped and the real verdict used. Ticket `gs-mt1hcg3tae` was created
and **no reply** sent (no outbox row; sends stayed at 3/300).
- **Gmail (founder):** the verdict was `dkim: pass, spf: pass,
dmarc: pass` (header.from gmail.com), the first live DKIM verification
by OpenDKIM. Ticket `gs-rcce2nne4k` was created and the reply sent
through TEM (sends 4/300).
- T05 is complete. The reference stack now meets all of spec §14
item 10.
## Residuals
Handed off as live work records (origin: residual, origin_ref MAS-WP-0005):
- MAS-WP-0008: finalize v0.2 (wording pass, bundle conformance cases for
v0.2, release decision).
- MAS-WP-0004 (existing) carries the milter/header_checks lessons for the
Postfix profile; MAS-WP-0006 (existing) builds on
`generator.for_destination`.