MAS-WP-0005-T07: v0.2 draft bundle; mas_core accepts v0.1 and v0.2 statutes

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 22:19:34 +02:00
parent 3709fbb54b
commit a12e1c4370
17 changed files with 1814 additions and 20 deletions

View file

@ -0,0 +1,55 @@
# MAS specification changelog
## v0.2 (draft)
New features, opt-in per statute with `"mas_version": "0.2"`
(mail-address-semantics MAS-WP-0005; proposals SCP-6 and SCP-7 in
`docs/spec-change-proposals.md`).
- **Stand-ins (SCP-6), §6.3.** `--` for `=` and `---` for `!`, fixed by
the spec and switched on by `alternate_syntax.resource` and
`alternate_syntax.interaction`. Matching order: interaction first,
then resource. No stand-in for `+`.
- **Destination transforms (SCP-6), §12.1 and §12.2.** There is a closed
set of five generator transforms. The canonical form is preferred, and
the generator refuses when the statute does not permit a needed
transform. An informative section covers integrations, with IONOS and
Google Groups as examples.
- **Resource-identifier case (SCP-7), §8.**
`equivalence.resource_id_case: exact | insensitive`.
- **Length, §13.** The length is re-checked after transforms.
- **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)
Errata from the reference experiment and the live provider test
(mail-address-semantics MAS-WP-0002/0003). Proposals and evidence:
`docs/spec-change-proposals.md` in the implementation repository.
- **SCP-2, resource types are lower-case.** `resource-type` is now
lower-case words of `a-z`/`0-9` joined by single `-` or `_` (§6.1,
grammar, statute schema `resource_types` key pattern). `Ticket=PR42`,
`my--type=X` and `type-=X` are no longer canonical. New fixtures: p1-042
to p1-045.
- **SCP-3, type intake.** Address registry entries may carry
`intake_for: TYPE` (statute schema). This explicitly makes a function
address the intake for a declared resource type (§7). An opaque target
equal to a type name has no intake semantics without it.
- **SCP-5, intake auto-reply bounds.** New §14 item 10: auto-replies to
intake or interaction addresses SHOULD require an authenticated sender,
MUST be volume-bounded, and MUST NOT go to automated mail.
- **SCP-7, case-robust identifiers (recommendation).** §6.2: generators
SHOULD use identifiers whose meaning does not depend on case, and
receivers SHOULD match them case-insensitively. The statute field
`resource_id_case` is deferred to v0.2.
## v0.1
Initial draft.

View file

@ -0,0 +1,83 @@
# MAS v0.1 Conformance
## 1. Test surfaces
An implementation can claim any of the following independently:
- `MAS-P1-PARSER`
- `MAS-P2-PARSER`
- `MAS-V0.1-GENERATOR`
- `MAS-V0.1-STATUTE`
## 2. Parser requirements
A parser claiming `MAS-P1-PARSER` MUST produce the expected result for every
fixture whose `profile` is `p1`.
A parser claiming `MAS-P2-PARSER` MUST produce the expected result for every
fixture whose `profile` is `p2`, and MUST also satisfy `MAS-P1-PARSER`.
The `parsed` object in the fixture file is normative for the test corpus.
A parser MUST NOT infer `person`, `actor`, or `function` from `.`, `-`, `_`.
## 3. Generator requirements
A generator:
- MUST emit only grammar-valid canonical ASCII local-parts;
- MUST NOT emit empty target, resource type, resource id, facet, or interaction;
- MUST emit `+` before `!`;
- MUST NOT emit more than one `+` or one `!`;
- MUST NOT emit `$` or `^`;
- MUST NOT emit both `+` and `!` in v0.1;
- MUST keep the encoded SMTP local-part at or below 64 octets.
Fixtures with `generator_allowed: false` may still be accepted by a P2 parser,
but a conforming generator MUST NOT create them.
## 4. Statute requirements
A statute implementation MUST validate discovery documents using
`schema/mas-statute-v0.1.schema.json`.
It MUST NOT apply:
- case folding,
- dot folding,
- address classification,
- resource-type semantics,
- interaction-mode semantics,
unless the exact-host statute successfully validates and declares them.
## 5. Discovery failure
Failure to retrieve or validate a statute:
- does not invalidate the email address;
- does invalidate any claim that MAS namespace semantics were discovered;
- falls back to ordinary email handling plus exact local configuration.
## 6. Inheritance
Implementations are not required to implement `inherits`.
Implementations that do implement it MUST:
- fetch the explicit URI only;
- detect cycles;
- treat inherited data as defaults;
- never inherit explicit `addresses` registry entries;
- let the child override inherited resource types and interaction modes.
## 7. Test command
The bundled reference parser can be run with:
```bash
python reference/mas_parser.py --self-test
```
The reference parser is non-normative; the grammar, specification, schema,
and fixtures are normative for this draft.

32
mas-v0.2-spec/README.md Normal file
View file

@ -0,0 +1,32 @@
# Mail Address Semantics (MAS) v0.2 (draft) — Specification Bundle
This bundle is the **draft** of MAS v0.2. It extends v0.1.1 (see `CHANGELOG.md`).
## Contents
- `SPECIFICATION.md` — normative protocol/convention text (v0.2 draft)
- `CHANGELOG.md` — errata applied since v0.1
- `grammar/mas-v0.2.abnf` — canonical ASCII local-part grammar (unchanged from v0.1.1)
- `schema/mas-statute-v0.2.schema.json` — statute discovery document schema
- `examples/mas-statute.example.json` — example statute
- `CONFORMANCE.md` — parser/generator/statute conformance rules
- `conformance/addresses.json` — positive and negative address fixtures
- `reference/mas_parser.py` — small non-normative reference parser
## Profiles
- **P1 / Naming + Resource:** naming hints, `=` resource binding, `+` facet
- **P2 / Interaction:** optional `!` interaction preference
- **P3 / Economic:** reserved only; `$` and `^` have no v0.1 semantics
The normative machine grammar is deliberately ASCII-only. SMTPUTF8/Unicode
addresses may exist, but Unicode MAS semantics are not part of the canonical
v0.1 grammar.
## Discovery status
`/.well-known/mas` is specified here as the intended discovery location.
RFC 8615 requires new well-known URI suffixes to be registered for general
Internet use. Until such registration exists, this path should be considered
a draft/controlled-deployment convention rather than an IANA-registered
Internet-wide well-known URI.

View file

@ -0,0 +1,589 @@
# Mail Address Semantics (MAS) v0.2 (draft)
## 1. Status and normative language
This document is a **draft** of MAS v0.2. It includes v0.1.1 and adds
statute-enabled stand-ins for `=` and `!`, destination transforms for
generators, and a statute-declared resource-identifier case rule (see
`CHANGELOG.md`). The canonical grammar is unchanged from v0.1.1.
A statute opts into v0.2 features by declaring `"mas_version": "0.2"`. A
v0.1 statute stays valid and is interpreted under v0.1.1 rules.
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**,
**RECOMMENDED**, **MAY**, and **OPTIONAL** are to be interpreted as described
in BCP 14 when, and only when, they appear in uppercase.
MAS is an overlay on ordinary email addressing. It does not replace SMTP,
establish identity, grant authority, define delivery topology, or create
contractual commitments.
## 2. Scope
MAS v0.1 standardizes:
1. non-structural naming hints for people, actors, and functions;
2. structured resource targets using `=`;
3. facets using `+`;
4. an optional interaction profile using `!`;
5. exact-host statute discovery;
6. conservative equivalence behavior;
7. parser and generator conformance.
`$` and `^` are reserved. They have no MAS v0.1 meaning.
## 3. Profiles
### 3.1 Profile P1 — Naming + Resource
P1 is the base v0.1 profile.
- `.` MAY be used as a person-style naming separator.
- `-` MAY be used as an actor-style naming separator.
- `_` MAY be used as a function-style naming separator.
- `=` structurally binds a resource type to a resource identifier.
- `+` structurally adds one facet.
`.`, `-`, and `_` never classify an address by themselves.
### 3.2 Profile P2 — Interaction
P2 extends P1 with `!`.
`TARGET!MODE` requests that the addressed endpoint continue the interaction
using `MODE` as the preferred interaction mode.
The address does not state which party initiates a non-email leg and does not
constitute acceptance. Acceptance is external to the address.
### 3.3 Profile P3 — Economic
P3 is not defined in v0.1.
`$` and `^` are reserved so that later profiles may define consideration and
conditions without creating v0.1 parsing ambiguity.
## 4. Canonical ASCII local-part
The normative v0.1 machine grammar is the ABNF in
`grammar/mas-v0.2.abnf`.
Canonical MAS v0.1 local-parts are unquoted ASCII dot-atom-like strings.
Quoted local-parts and SMTPUTF8 local-parts are outside the canonical v0.1
machine grammar.
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.
A target without `=` is opaque. Its semantic kind MUST NOT be inferred from
`.`, `-`, or `_`.
If a target contains `=`, the target is a resource target:
- text before the first `=` is `resource-type`;
- text after the first `=` is `resource-id`;
- neither may be empty;
- subsequent `=` characters are resource-id data.
Examples:
ticket=PR42
invoice=RE=2026-4711
The second example has resource type `invoice` and resource id
`RE=2026-4711`.
### 4.2 Facet
A facet is introduced by `+`.
`+` MAY occur at most once and its value MUST be non-empty.
ticket=PR42+comments
The base target remains meaningful if a mail system strips the facet. MAS
therefore treats facet loss as graceful degradation:
ticket=PR42+comments -> ticket=PR42
This is a semantic design property, not a preservation guarantee.
### 4.3 Interaction
An interaction preference is introduced by `!`.
`!` MAY occur at most once and its value MUST be non-empty.
P2 examples:
ticket!callback
ticket=PR42!web
Canonical operator order is:
TARGET [+FACET] [!INTERACTION]
Non-canonical order is invalid MAS syntax. A conforming parser MUST reject it
and MUST NOT silently reorder it.
### 4.4 Composition rule
A parser implementing P2 MUST be able to parse the canonical combined form:
TARGET+FACET!INTERACTION
A v0.1 generator MUST NOT emit both `+` and `!` in the same generated
local-part. Implementations requiring both semantics SHOULD move one of them
to external metadata.
This deliberately makes receivers slightly more tolerant than generators
while retaining one canonical parse.
### 4.5 Reserved characters
Within canonical MAS v0.1 structured values:
+ ! $ ^
are reserved.
MAS v0.1 defines no escaping mechanism.
A percent-looking sequence such as `%2B` is ordinary data in v0.1 and MUST NOT
be percent-decoded by the MAS parser.
## 5. Naming semantics
The following are style conventions only:
bernd.worsch@example.org person-like
research-companion@example.org actor-like
invoice_ingest@example.org function-like
They are not classifier rules.
Examples such as these remain opaque without statute metadata:
anne-marie.schmidt@example.org
support@example.org
happy-helper.v2@example.org
A domain registry or statute metadata determines semantic kind.
## 6. Resource semantics
A resource address identifies a communication endpoint associated with a
resource.
ticket=PR42@example.org
It does not grant access to, mutation of, or knowledge about the resource.
Resource addressability and resource mutability are independent.
A closed resource MAY remain addressable. A receiver MAY archive the mail,
reject mutation, return lifecycle information, or create a linked follow-up
resource.
### 6.1 Resource types
A resource type is lower-case: one or more words of `a-z` and `0-9`, joined
by single `-` or `_` characters (grammar rule `resource-type`). A
local-part whose part before the first `=` is not a valid resource type,
such as `Ticket=PR42`, is not a canonical MAS address.
Resource types are protocol tokens, not user data. Forwarders and list
servers commonly lower-case local-parts, so mixed-case types would not
survive transit.
A statute SHOULD enumerate resource types it recognizes.
Unknown resource types MUST NOT acquire application semantics solely because
they use `=` syntax.
### 6.2 Resource identifiers
Resource identifiers are correlation identifiers, not credentials.
Predictable identifiers are enumerable. Deployments SHOULD prefer
non-guessable external identifiers when addresses are widely forwarded or
exposed and enumeration is undesirable.
High entropy does not transform an identifier into an authorization token.
Forwarders, mailing lists and web forms commonly change the case of
local-parts. Generators SHOULD use resource identifiers whose meaning does
not depend on letter case, for example lower-case only or a
case-insensitive alphabet such as Crockford base32, and receivers SHOULD
then match them case-insensitively.
### 6.3 Stand-ins (v0.2)
Some endpoints, for example provider forwarding forms, refuse `=` or `!` in
an address. v0.2 defines two fixed **stand-ins** built from dashes only:
| Stand-in | Stands for | Statute switch |
|---|---|---|
| `--` | `=` (resource separator) | `alternate_syntax.resource: true` |
| `---` | `!` (interaction) | `alternate_syntax.interaction: true` |
The stand-ins are defined by this specification and are not configurable.
`+` has no stand-in; it is functional by default, and facet loss degrades
safely (§12).
A stand-in has meaning only in a namespace whose statute enables it.
Without the switch, `--` and `---` are ordinary characters (§6.1). A
stand-in form is not canonical: the parser treats it as an opaque target,
and only a statute-aware implementation interprets it.
**Matching order.** A statute-aware implementation interprets a local-part
in this order:
1. **Interaction.** If the interaction stand-in is enabled and the
local-part ends in `---MODE`, where `MODE` is an interaction mode the
statute enables, the suffix after the last such `---` is the
interaction, equivalent to `!MODE`.
2. **Resource.** If the resource stand-in is enabled and the remaining
target has the form `TYPE--ID`, where `TYPE` is a resource type the
statute declares, it is equivalent to `TYPE=ID`. The split is at the
first `--`.
| Received | Equivalent canonical form |
|---|---|
| `ticket---callback` | `ticket!callback` |
| `ticket--gs1---callback` | `ticket=gs1!callback` |
| `ticket--a---b` (`b` not an enabled mode) | `ticket=a---b` |
| `ticket--gs1+comments` | `ticket=gs1+comments` |
**Constraints.**
- Resource-type names never contain `--` (grammar, §6.1).
- Interaction mode names MUST NOT start with `-`.
- A namespace that enables the interaction stand-in MUST NOT issue resource
identifiers containing `---`.
- A namespace with existing opaque names matching a stand-in pattern MUST
NOT enable that stand-in.
Receivers MUST store, correlate and reply using the canonical form. The
equivalence key (§8) is always computed from the canonical form.
## 7. Interaction semantics
The recommended P2 initiation pattern is:
ticket!callback@great-service.example
followed by creation or disclosure of a resource:
ticket=GS-41872@great-service.example
The first address expresses interaction intent. The second names the resource
created or selected by the service.
`!callback` is a preference/request, not a command, guarantee, SLA, or proof
of acceptance.
The bare target in `ticket!callback` is an opaque target. It has no link
to the resource type `ticket` merely because the names are equal. A
statute declares the link on the address registry entry:
"addresses": { "ticket": { "kind": "function", "intake_for": "ticket" } }
An address whose base target is registered with `intake_for: T` is the
*intake* for resource type `T`: mail to it requests creation or selection
of a resource of type `T`. The entry MUST have `kind` `function`, and `T`
MUST be a resource type the statute declares or inherits. Without such a
registration a target has no intake semantics, even if it equals a declared
resource type.
## 8. Address equivalence
MAS does not impose case folding, dot folding, alias folding, or Unicode
normalization in the absence of a successfully discovered statute.
If no statute is available, comparison is exact over the presented canonical
ASCII local-part.
A statute MAY declare:
- `exact` local-part comparison;
- `ascii-insensitive` local-part comparison;
- dot folding for explicitly named address kinds or registry entries.
Dot folding MUST NOT be assumed by default.
A v0.2 statute MAY declare `equivalence.resource_id_case`:
- `exact` (default): resource identifiers compare exactly, subject to
`local_part_case`;
- `insensitive`: resource identifiers compare ASCII case-insensitively,
even when `local_part_case` is `exact`. Nothing else is folded.
Generators for such a namespace SHOULD emit identifiers in lower case
(§6.2).
A-label/U-label domain processing follows the underlying IDNA-aware HTTPS and
DNS stack; MAS does not invent a second IDN mapping algorithm.
## 9. Unicode
SMTPUTF8 allows non-ASCII local-parts, but MAS v0.1 does not define a
canonical Unicode local-part grammar.
A statute may declare `unicode_local_part: true` for experimental/private
extensions. Such addresses MUST NOT be advertised as canonical MAS v0.1
machine syntax.
A future MAS version may define NFC normalization and Unicode comparison
rules. v0.1 deliberately avoids doing so.
## 10. Statute discovery
The intended discovery URI is:
https://HOST/.well-known/mas
where `HOST` is the exact domain appearing after `@`.
For:
anna.mueller@acme.coulomb.social
the lookup host is:
acme.coulomb.social
A client MUST NOT walk to parent domains implicitly.
### 10.1 Registration caveat
RFC 8615 requires applications minting a new well-known URI suffix to
register it for general Internet use.
Therefore `/.well-known/mas` is a candidate MAS discovery location in this
draft. General public deployment SHOULD wait for the required registration.
Controlled deployments MAY use it by agreement.
### 10.2 Retrieval
A statute-aware client:
1. constructs the HTTPS URI for the exact host;
2. performs an HTTPS GET;
3. requires a successful 2xx response;
4. requires a document conforming to the v0.1 statute schema;
5. verifies that `namespace` equals the queried host after normal domain-name
canonicalization;
6. otherwise treats discovery as failed.
Discovery failure does not make the email address invalid. It means MAS
semantics are unconfirmed.
### 10.3 Redirects
Clients SHOULD NOT follow cross-origin redirects for statute discovery.
Same-origin redirects MAY be followed.
A cross-origin policy transfer requires an explicit future delegation
mechanism and is not defined in v0.1.
### 10.4 Inheritance
No parent-domain inheritance is implicit.
A child statute MAY contain an `inherits` URI. v0.1 inheritance is
**defaults-only**:
- the parent document is fetched and validated independently;
- the child remains authoritative for its exact namespace;
- child values override inherited defaults;
- address registry entries are never inherited;
- resource types and interaction modes MAY be inherited;
- an inheritance cycle is invalid.
Implementations MAY choose not to implement inheritance. If they do not,
they MUST ignore `inherits` rather than walking DNS parents.
## 11. Statute document
The normative schema is:
schema/mas-statute-v0.2.schema.json
The document describes:
- namespace and version;
- enabled profiles;
- accepted resource types;
- accepted interaction modes;
- equivalence policy;
- optional explicit address classification;
- optional inheritance;
- optional stand-in switches (`alternate_syntax`, v0.2);
- optional resource-identifier case rule (`equivalence.resource_id_case`, v0.2).
The statute is metadata, not an authorization database.
## 12. Deliverability and rewriting
RFC-validity is weaker than ecosystem compatibility.
Mailing lists, SRS, VERP, gateways, web forms, validators, and security
products may reject or rewrite otherwise legal local-parts.
Deployments MUST NOT assume preservation of MAS syntax through arbitrary
rewriting.
`+` is intentionally degradation-friendly: loss of a facet leaves the base
target.
`!` is intended primarily for cooperating environments where all relevant
systems are known to preserve it. Where an endpoint refuses it, a namespace
can enable the `---` stand-in (§6.3).
### 12.1 Destination transforms (v0.2)
A generator produces an address for a *destination*: a mail header, a
forwarding form, a list membership, a printed page. A **destination
transform** changes an address so that it fits a destination's constraints.
The set is closed:
| Transform | Effect | Allowed only if |
|---|---|---|
| `resource-alternate` | `TYPE=ID` → `TYPE--ID` | the statute enables `alternate_syntax.resource` |
| `interaction-alternate` | `!MODE` → `---MODE` | the statute enables `alternate_syntax.interaction` and `MODE` |
| `lowercase` | fold the local-part to lower case | the statute declares `resource_id_case: insensitive` |
| `drop-facet` | remove `+FACET` | always; only on explicit request |
| `drop-interaction` | remove `!MODE` | always; only on explicit request, with a warning |
A generator:
- MUST emit the canonical form when the destination allows it;
- MAY apply the transforms a destination needs, and only those the
namespace's statute permits;
- MUST refuse, rather than emit a form the receiver would misread, when the
destination needs a transform the statute does not permit;
- MUST check the length limit (§13) after all transforms and refuse rather
than truncate.
Outbound service mail (From, Reply-To) stays canonical unless a destination
transform is known to be needed.
### 12.2 Integrating with constrained endpoints (informative)
To integrate with an endpoint that rejects or rewrites MAS syntax:
1. Observe what the endpoint refuses or rewrites.
2. Enable the matching acceptance in the statute (stand-ins, identifier
case).
3. Describe the endpoint as a **destination profile** in the generator's
configuration: the list of transforms it needs.
Concrete profiles are implementation configuration, not part of this
specification.
*Example.* An IONOS mail-forward form (observed 2026-10) refuses `=` and
forces lower case. With a statute that enables `alternate_syntax.resource`
and declares `resource_id_case: insensitive`, the profile
`resource-alternate` + `lowercase` turns `ticket=GS-85QJ8WC09P+comments`
into `ticket--gs-85qj8wc09p+comments`, which the receiving namespace
correlates to the original resource. Google Groups accepts `=` but stores
members in lower case, so it needs only `lowercase`.
## 13. Length
An SMTP local-part is limited to 64 octets.
Generators MUST enforce the underlying transport limit after encoding and
after destination transforms. `--` adds one octet and `---` adds two.
Because the budget is small, v0.1 permits at most one facet and one
interaction, and generators MUST NOT emit both simultaneously.
## 14. Security
1. MAS identifiers are not credentials.
2. Resource knowledge grants no authority.
3. Interaction selectors are not commands.
4. Dynamic addresses MUST define handling for bounces, unknown resources,
expired resources, verification traffic, and abuse.
5. Receivers MUST avoid backscatter behavior.
6. Syntax-derived classifications MUST NOT substitute for statute metadata.
7. `$` and `^` MUST NOT be rendered as payment/contract semantics in v0.1.
8. Authentication remains external to MAS.
9. A statute endpoint is security-relevant configuration and SHOULD be
protected with the same care as other origin-wide metadata.
10. A receiver that automatically replies to mail sent to an intake or
interaction address turns any forged sender into outbound mail to a
third party. Such a receiver SHOULD reply only when the sender is
authenticated (for example DMARC alignment pass), MUST bound the volume
of such replies (rate limits or caps), and MUST NOT reply to automated
mail (`Auto-Submitted` other than `no`, or `Precedence` `bulk`, `list`
or `junk`).
## 15. Conformance classes
### 15.1 P1 parser
A P1 parser:
- validates canonical ASCII syntax;
- parses resource binding and facet;
- rejects `!`, `$`, and `^` as MAS operators;
- does not classify opaque target kind by punctuation.
### 15.2 P2 parser
A P2 parser additionally parses `!`.
### 15.3 Generator
A generator:
- emits only canonical operator order;
- emits no empty component;
- emits no reserved operator within a value;
- emits at most one `+`;
- emits at most one `!`;
- MUST NOT emit both `+` and `!` in v0.1;
- enforces the SMTP local-part octet limit.
### 15.4 Statute-aware implementation
A statute-aware implementation additionally validates the discovery document
against the normative schema and applies only explicitly declared equivalence
and classification rules.
## 16. Reserved future work
Future specifications may define:
- Unicode canonical grammar;
- escaping;
- signed statutes and signed semantic assertions;
- interaction negotiation;
- service discovery;
- economic request/offer/acceptance/settlement;
- condition tokens;
- IANA registrations.
## 17. One-sentence definition
**Mail Address Semantics is a voluntary convention for naming people, actors
and functions, and for addressing resources, facets, and optional interaction
preferences through ordinary email addresses while keeping classification,
authority, authentication, equivalence, and delivery implementation explicit
and external to the address syntax.**

View file

@ -0,0 +1,354 @@
[
{
"id": "p1-001",
"local_part": "bernd.worsch",
"profile": "p1",
"valid": true,
"parsed": {
"target": "bernd.worsch",
"target_kind": "opaque",
"facet": null,
"interaction": null
}
},
{
"id": "p1-002",
"local_part": "anne-marie.schmidt",
"profile": "p1",
"valid": true,
"parsed": {
"target": "anne-marie.schmidt",
"target_kind": "opaque",
"facet": null,
"interaction": null
}
},
{
"id": "p1-003",
"local_part": "support",
"profile": "p1",
"valid": true,
"parsed": {
"target": "support",
"target_kind": "opaque",
"facet": null,
"interaction": null
}
},
{
"id": "p1-004",
"local_part": "invoice-reviewer_backup",
"profile": "p1",
"valid": true,
"parsed": {
"target": "invoice-reviewer_backup",
"target_kind": "opaque",
"facet": null,
"interaction": null
}
},
{
"id": "p1-005",
"local_part": "happy-helper.v2",
"profile": "p1",
"valid": true,
"parsed": {
"target": "happy-helper.v2",
"target_kind": "opaque",
"facet": null,
"interaction": null
}
},
{
"id": "p1-010",
"local_part": "ticket=PR42",
"profile": "p1",
"valid": true,
"parsed": {
"target": "ticket=PR42",
"target_kind": "resource",
"resource_type": "ticket",
"resource_id": "PR42",
"facet": null,
"interaction": null
}
},
{
"id": "p1-011",
"local_part": "invoice=RE=2026-4711",
"profile": "p1",
"valid": true,
"parsed": {
"target": "invoice=RE=2026-4711",
"target_kind": "resource",
"resource_type": "invoice",
"resource_id": "RE=2026-4711",
"facet": null,
"interaction": null
}
},
{
"id": "p1-012",
"local_part": "ticket=PR42+comments",
"profile": "p1",
"valid": true,
"parsed": {
"target": "ticket=PR42",
"target_kind": "resource",
"resource_type": "ticket",
"resource_id": "PR42",
"facet": "comments",
"interaction": null
}
},
{
"id": "p1-013",
"local_part": "bernd.worsch+github",
"profile": "p1",
"valid": true,
"parsed": {
"target": "bernd.worsch",
"target_kind": "opaque",
"facet": "github",
"interaction": null
}
},
{
"id": "p1-020",
"local_part": "=PR42",
"profile": "p1",
"valid": false,
"error": "empty resource type"
},
{
"id": "p1-021",
"local_part": "ticket=",
"profile": "p1",
"valid": false,
"error": "empty resource id"
},
{
"id": "p1-022",
"local_part": "ticket=PR42+",
"profile": "p1",
"valid": false,
"error": "empty facet"
},
{
"id": "p1-023",
"local_part": "ticket=PR42+comments+attachments",
"profile": "p1",
"valid": false,
"error": "multiple facet operators"
},
{
"id": "p1-024",
"local_part": "ticket=PR42$4.99",
"profile": "p1",
"valid": false,
"error": "reserved operator"
},
{
"id": "p1-025",
"local_part": "ticket=PR42^1h",
"profile": "p1",
"valid": false,
"error": "reserved operator"
},
{
"id": "p1-026",
"local_part": ".bernd",
"profile": "p1",
"valid": false,
"error": "leading dot"
},
{
"id": "p1-027",
"local_part": "bernd.",
"profile": "p1",
"valid": false,
"error": "trailing dot"
},
{
"id": "p1-028",
"local_part": "bernd..worsch",
"profile": "p1",
"valid": false,
"error": "repeated dot"
},
{
"id": "p1-029",
"local_part": "ticket=PR.42",
"profile": "p1",
"valid": true,
"parsed": {
"target": "ticket=PR.42",
"target_kind": "resource",
"resource_type": "ticket",
"resource_id": "PR.42",
"facet": null,
"interaction": null
}
},
{
"id": "p1-030",
"local_part": "ticket=PR%2B42",
"profile": "p1",
"valid": true,
"parsed": {
"target": "ticket=PR%2B42",
"target_kind": "resource",
"resource_type": "ticket",
"resource_id": "PR%2B42",
"facet": null,
"interaction": null
},
"note": "%2B is literal data; MAS v0.1 does not percent-decode"
},
{
"id": "p2-001",
"local_part": "ticket!callback",
"profile": "p2",
"valid": true,
"parsed": {
"target": "ticket",
"target_kind": "opaque",
"facet": null,
"interaction": "callback"
},
"generator_allowed": true
},
{
"id": "p2-002",
"local_part": "ticket=PR42!web",
"profile": "p2",
"valid": true,
"parsed": {
"target": "ticket=PR42",
"target_kind": "resource",
"resource_type": "ticket",
"resource_id": "PR42",
"facet": null,
"interaction": "web"
},
"generator_allowed": true
},
{
"id": "p2-003",
"local_part": "ticket=PR42+comments!callback",
"profile": "p2",
"valid": true,
"parsed": {
"target": "ticket=PR42",
"target_kind": "resource",
"resource_type": "ticket",
"resource_id": "PR42",
"facet": "comments",
"interaction": "callback"
},
"generator_allowed": false
},
{
"id": "p2-010",
"local_part": "ticket!",
"profile": "p2",
"valid": false,
"error": "empty interaction"
},
{
"id": "p2-011",
"local_part": "ticket!callback!chat",
"profile": "p2",
"valid": false,
"error": "multiple interaction operators"
},
{
"id": "p2-012",
"local_part": "ticket!callback+comments",
"profile": "p2",
"valid": false,
"error": "non-canonical operator order"
},
{
"id": "p1-031",
"local_part": "ticket!callback",
"profile": "p1",
"valid": false,
"error": "interaction not enabled in P1"
},
{
"id": "p2-013",
"local_part": "ticket=PR42+comments!callback$4.99",
"profile": "p2",
"valid": false,
"error": "reserved operator"
},
{
"id": "p2-014",
"local_part": "ticket=PR42+comments!call.back",
"profile": "p2",
"valid": true,
"parsed": {
"target": "ticket=PR42",
"target_kind": "resource",
"resource_type": "ticket",
"resource_id": "PR42",
"facet": "comments",
"interaction": "call.back"
},
"generator_allowed": false
},
{
"id": "p1-040",
"local_part": "\"ticket=PR42\"",
"profile": "p1",
"valid": false,
"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",
"local_part": "tïcket=PR42",
"profile": "p1",
"valid": false,
"error": "non-ASCII outside canonical MAS v0.1"
},
{
"id": "p1-042",
"local_part": "Ticket=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): resource types are lower-case"
},
{
"id": "p1-043",
"local_part": "my--type=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): no doubled separator in resource types"
},
{
"id": "p1-044",
"local_part": "type-=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): no trailing separator"
},
{
"id": "p1-045",
"local_part": "support-case_v2=PR42",
"profile": "p1",
"valid": true,
"parsed": {
"target": "support-case_v2=PR42",
"target_kind": "resource",
"resource_type": "support-case_v2",
"resource_id": "PR42",
"facet": null,
"interaction": null
}
}
]

View file

@ -0,0 +1,59 @@
{
"mas_version": "0.2",
"namespace": "coulomb.social",
"profiles": {
"p1": true,
"p2": true
},
"resource_types": {
"ticket": {
"enabled": true,
"description": "Customer-service or work ticket",
"identifier_policy": "non-guessable-preferred"
},
"invoice": {
"enabled": true,
"description": "Invoice communication resource",
"identifier_policy": "opaque"
}
},
"interaction_modes": {
"callback": {
"enabled": true,
"description": "Continue by a voice callback"
},
"web": {
"enabled": true,
"description": "Continue in a web interface"
},
"chat": {
"enabled": true,
"description": "Continue in a chat interface"
}
},
"equivalence": {
"local_part_case": "exact",
"dot_folding": false,
"resource_id_case": "insensitive"
},
"unicode_local_part": false,
"addresses": {
"support": {
"kind": "function",
"description": "General support intake"
},
"bernd.worsch": {
"kind": "person"
},
"research-companion": {
"kind": "actor"
},
"invoice_ingest": {
"kind": "function"
}
},
"alternate_syntax": {
"resource": true,
"interaction": true
}
}

View file

@ -0,0 +1,62 @@
; Mail Address Semantics (MAS) v0.2 (draft)
; The canonical grammar is unchanged from v0.1.1. The v0.2 stand-ins
; ("--" for "=", "---" for "!") are not canonical syntax: they parse as
; opaque targets and are interpreted only by statute-aware
; implementations (SPECIFICATION section 6.3).
; Canonical ASCII local-part grammar
;
; This grammar describes the local-part only. Domain syntax is provided by
; the underlying email specifications.
;
; P1 accepts: target [facet]
; P2 accepts: target [facet] [interaction]
;
; Generators MUST NOT emit both facet and interaction in v0.1 even though
; P2 parsers accept the combined canonical form.
mas-local-p1 = target [facet]
mas-local-p2 = target [facet] [interaction]
target = resource-target / opaque-target
resource-target = resource-type "=" resource-id
opaque-target = dot-string
; v0.1.1 (SCP-2): lower-case words joined by single "-" or "_".
; Never upper case, never "--", "__" or a leading/trailing separator.
resource-type = type-word *(("-" / "_") type-word)
type-word = 1*(LCALPHA / DIGIT)
; Resource identifiers may contain additional "=" characters as data.
resource-id = resource-id-segment *("." resource-id-segment)
resource-id-segment = resource-id-char *resource-id-char
facet = "+" value
interaction = "!" value
value = value-segment *("." value-segment)
value-segment = value-char *value-char
; dot-string prevents leading, trailing, or repeated ".".
dot-string = atom *("." atom)
atom = atom-char *atom-char
; Conservative canonical ASCII subset derived from RFC 5322 atext while
; reserving MAS operators + ! $ ^ and "=" where structural.
;
; ALPHA / DIGIT are imported from RFC 5234 core rules.
atom-char = ALPHA / DIGIT /
"#" / "%" / "&" / "'" / "*" / "-" / "/" / "?" /
"_" / "`" / "{" / "|" / "}" / "~"
LCALPHA = %x61-7A ; a-z
resource-id-char = atom-char / "="
value-char = atom-char / "="
; Explicitly excluded from canonical values:
; "+" "!" "$" "^" "@"
;
; Quoted local-parts are outside canonical MAS v0.1.
; SMTPUTF8 local-parts are outside canonical MAS v0.1 machine grammar.

View file

@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Non-normative MAS v0.1 canonical ASCII reference parser."""
import argparse
import json
import re
from pathlib import Path
ATOM = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789#%&'*-/ ?_`{|}~".replace(" ", ""))
RTYPE = re.compile(r"[a-z0-9]+(?:[_-][a-z0-9]+)*") # v0.1.1: lower-case, single separators
RESERVED = set("+!$^")
def _valid_dot_string(s, allowed):
if not s or s.startswith(".") or s.endswith(".") or ".." in s:
return False
return all((c == "." or c in allowed) for c in s)
def parse(local_part, profile="p1"):
if not isinstance(local_part, str) or not local_part:
raise ValueError("empty local-part")
try:
local_part.encode("ascii")
except UnicodeEncodeError:
raise ValueError("non-ASCII outside canonical MAS v0.1")
if '"' in local_part or "\\" in local_part or "@" in local_part:
raise ValueError("quoted/escaped/full address outside canonical local-part grammar")
if "$" in local_part or "^" in local_part:
raise ValueError("reserved operator")
if local_part.count("+") > 1:
raise ValueError("multiple facet operators")
if local_part.count("!") > 1:
raise ValueError("multiple interaction operators")
plus = local_part.find("+")
bang = local_part.find("!")
if plus >= 0 and bang >= 0 and bang < plus:
raise ValueError("non-canonical operator order")
if profile == "p1" and bang >= 0:
raise ValueError("interaction not enabled in P1")
if profile not in ("p1", "p2"):
raise ValueError("unknown profile")
interaction = None
s = local_part
if bang >= 0:
interaction = s[bang+1:]
if not interaction:
raise ValueError("empty interaction")
s = s[:bang]
if not _valid_dot_string(interaction, ATOM | set("=")):
raise ValueError("invalid interaction")
facet = None
plus = s.find("+")
if plus >= 0:
facet = s[plus+1:]
if not facet:
raise ValueError("empty facet")
s = s[:plus]
if not _valid_dot_string(facet, ATOM | set("=")):
raise ValueError("invalid facet")
target = s
if not target:
raise ValueError("empty target")
out = {
"target": target,
"target_kind": "opaque",
"facet": facet,
"interaction": interaction,
}
if "=" in target:
rtype, rid = target.split("=", 1)
if not rtype:
raise ValueError("empty resource type")
if not rid:
raise ValueError("empty resource id")
if not RTYPE.fullmatch(rtype):
raise ValueError("invalid resource type")
if not _valid_dot_string(rid, ATOM | set("=")):
raise ValueError("invalid resource id")
out.update({
"target_kind": "resource",
"resource_type": rtype,
"resource_id": rid,
})
else:
if not _valid_dot_string(target, ATOM):
raise ValueError("invalid opaque target")
if len(local_part.encode("ascii")) > 64:
raise ValueError("local-part exceeds 64 octets")
return out
def self_test():
here = Path(__file__).resolve().parents[1]
fixtures = json.loads((here / "conformance" / "addresses.json").read_text())
failures = []
for f in fixtures:
try:
got = parse(f["local_part"], f["profile"])
if not f["valid"]:
failures.append((f["id"], "expected invalid", got))
elif got != f["parsed"]:
failures.append((f["id"], "parse mismatch", {"got":got, "expected":f["parsed"]}))
except ValueError as e:
if f["valid"]:
failures.append((f["id"], "unexpected invalid", str(e)))
if failures:
print(json.dumps(failures, indent=2))
raise SystemExit(1)
print(f"PASS: {len(fixtures)} MAS v0.1 fixtures")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("local_part", nargs="?")
ap.add_argument("--profile", choices=["p1","p2"], default="p1")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args()
if args.self_test:
self_test()
elif args.local_part:
print(json.dumps(parse(args.local_part, args.profile), indent=2))
else:
ap.print_help()

View file

@ -0,0 +1,185 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.invalid/mas/schema/mas-statute-v0.2.schema.json",
"title": "Mail Address Semantics v0.2 Statute",
"type": "object",
"additionalProperties": false,
"required": [
"mas_version",
"namespace",
"profiles",
"equivalence"
],
"properties": {
"mas_version": {
"const": "0.2"
},
"namespace": {
"type": "string",
"minLength": 1,
"maxLength": 253,
"description": "Exact host governed by this statute, in its canonical DNS/IDNA presentation form."
},
"inherits": {
"type": "string",
"format": "uri",
"pattern": "^https://",
"description": "Optional explicit defaults-only parent statute URI. No DNS parent walking is implied."
},
"profiles": {
"type": "object",
"additionalProperties": false,
"required": [
"p1",
"p2"
],
"properties": {
"p1": {
"const": true
},
"p2": {
"type": "boolean"
}
}
},
"resource_types": {
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"description": {
"type": "string",
"maxLength": 500
},
"identifier_policy": {
"type": "string",
"enum": [
"opaque",
"non-guessable-preferred",
"sequential-allowed"
]
}
}
}
},
"interaction_modes": {
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[A-Za-z0-9_-]+$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"description": {
"type": "string",
"maxLength": 500
}
}
}
},
"equivalence": {
"type": "object",
"additionalProperties": false,
"required": [
"local_part_case",
"dot_folding"
],
"properties": {
"local_part_case": {
"type": "string",
"enum": [
"exact",
"ascii-insensitive"
]
},
"dot_folding": {
"type": "boolean"
},
"resource_id_case": {
"type": "string",
"enum": [
"exact",
"insensitive"
],
"default": "exact"
}
}
},
"unicode_local_part": {
"type": "boolean",
"default": false,
"description": "Experimental/private extension only; not canonical v0.1 machine syntax."
},
"addresses": {
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[A-Za-z0-9#%&'*\\-/=?_`{|}~.]+$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"person",
"actor",
"function",
"group",
"system",
"opaque"
]
},
"description": {
"type": "string",
"maxLength": 500
},
"intake_for": {
"type": "string",
"pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$",
"description": "v0.1.1 (SCP-3): this function is the intake for the named resource type"
}
}
}
},
"alternate_syntax": {
"type": "object",
"additionalProperties": false,
"default": {},
"description": "v0.2: enable the fixed stand-ins '--' for '=' and '---' for '!'",
"properties": {
"resource": {
"type": "boolean",
"default": false
},
"interaction": {
"type": "boolean",
"default": false
}
}
}
}
}

View file

@ -1,5 +1,5 @@
{
"mas_version": "0.2",
"mas_version": "0.3",
"namespace": "great-service.example",
"profiles": {
"p1": true,

View file

@ -21,6 +21,7 @@ from typing import Callable, Optional, Union
from urllib.parse import urljoin, urlsplit
from .statute import (
SUPPORTED_VERSIONS,
InheritanceCycle,
Statute,
StatuteInvalid,
@ -161,7 +162,7 @@ def _fetch_json(url: str, transport: Transport, timeout: float) -> tuple[str, ob
def _validate(doc, host: str, url: str) -> Statute:
if isinstance(doc, dict) and "mas_version" in doc and doc["mas_version"] != "0.1":
if isinstance(doc, dict) and "mas_version" in doc and doc["mas_version"] not in SUPPORTED_VERSIONS:
raise _Fail(FailureKind.UnsupportedVersion, f"mas_version {doc['mas_version']!r}", url)
try:
return build_statute(doc, host, statute_url=url)

View file

@ -0,0 +1,185 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.invalid/mas/schema/mas-statute-v0.2.schema.json",
"title": "Mail Address Semantics v0.2 Statute",
"type": "object",
"additionalProperties": false,
"required": [
"mas_version",
"namespace",
"profiles",
"equivalence"
],
"properties": {
"mas_version": {
"const": "0.2"
},
"namespace": {
"type": "string",
"minLength": 1,
"maxLength": 253,
"description": "Exact host governed by this statute, in its canonical DNS/IDNA presentation form."
},
"inherits": {
"type": "string",
"format": "uri",
"pattern": "^https://",
"description": "Optional explicit defaults-only parent statute URI. No DNS parent walking is implied."
},
"profiles": {
"type": "object",
"additionalProperties": false,
"required": [
"p1",
"p2"
],
"properties": {
"p1": {
"const": true
},
"p2": {
"type": "boolean"
}
}
},
"resource_types": {
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"description": {
"type": "string",
"maxLength": 500
},
"identifier_policy": {
"type": "string",
"enum": [
"opaque",
"non-guessable-preferred",
"sequential-allowed"
]
}
}
}
},
"interaction_modes": {
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[A-Za-z0-9_-]+$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"description": {
"type": "string",
"maxLength": 500
}
}
}
},
"equivalence": {
"type": "object",
"additionalProperties": false,
"required": [
"local_part_case",
"dot_folding"
],
"properties": {
"local_part_case": {
"type": "string",
"enum": [
"exact",
"ascii-insensitive"
]
},
"dot_folding": {
"type": "boolean"
},
"resource_id_case": {
"type": "string",
"enum": [
"exact",
"insensitive"
],
"default": "exact"
}
}
},
"unicode_local_part": {
"type": "boolean",
"default": false,
"description": "Experimental/private extension only; not canonical v0.1 machine syntax."
},
"addresses": {
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[A-Za-z0-9#%&'*\\-/=?_`{|}~.]+$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"person",
"actor",
"function",
"group",
"system",
"opaque"
]
},
"description": {
"type": "string",
"maxLength": 500
},
"intake_for": {
"type": "string",
"pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$",
"description": "v0.1.1 (SCP-3): this function is the intake for the named resource type"
}
}
}
},
"alternate_syntax": {
"type": "object",
"additionalProperties": false,
"default": {},
"description": "v0.2: enable the fixed stand-ins '--' for '=' and '---' for '!'",
"properties": {
"resource": {
"type": "boolean",
"default": false
},
"interaction": {
"type": "boolean",
"default": false
}
}
}
}
}

View file

@ -1,4 +1,4 @@
"""MAS v0.1 statute model, schema validation, semantic validation, inheritance.
"""MAS statute model (v0.1 and v0.2 draft), schema validation, semantic validation, inheritance.
Everything here is pure: documents come in as already-decoded JSON objects,
and inheritance resolution takes a caller-supplied `fetch` function. Network
@ -8,7 +8,7 @@ access lives in `mas_core.discovery` only.
from __future__ import annotations
import json
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from importlib import resources
from types import MappingProxyType
from typing import Callable, Mapping, Optional
@ -59,6 +59,10 @@ class Statute:
inherits: Optional[str] = None
unicode_local_part: bool = False
inherited_from: tuple[str, ...] = ()
mas_version: str = "0.1"
alt_resource: bool = False # v0.2: '--' stands for '='
alt_interaction: bool = False # v0.2: '---' stands for '!'
resource_id_case: str = "exact" # v0.2: "exact" | "insensitive"
document: Mapping = field(default_factory=dict, compare=False, repr=False)
def resource_type_enabled(self, name: str) -> bool:
@ -75,16 +79,26 @@ class Statute:
"profiles": {"p1": True, "p2": self.p2},
"resource_types": {k: dict(v) for k, v in self.resource_types.items()},
"interaction_modes": {k: dict(v) for k, v in self.interaction_modes.items()},
"equivalence": {"local_part_case": self.local_part_case, "dot_folding": self.dot_folding},
"mas_version": self.mas_version,
"equivalence": {"local_part_case": self.local_part_case, "dot_folding": self.dot_folding,
"resource_id_case": self.resource_id_case},
"alternate_syntax": {"resource": self.alt_resource, "interaction": self.alt_interaction},
"addresses": {k: dict(v) for k, v in self.addresses.items()},
"inherits": self.inherits,
"inherited_from": list(self.inherited_from),
}
_SCHEMA = json.loads(resources.files("mas_core").joinpath(
"schema/mas-statute-v0.1.schema.json").read_text(encoding="utf-8"))
_VALIDATOR = Draft202012Validator(_SCHEMA, format_checker=FormatChecker())
SUPPORTED_VERSIONS = ("0.1", "0.2")
def _validator(version: str) -> Draft202012Validator:
schema = json.loads(resources.files("mas_core").joinpath(
f"schema/mas-statute-v{version}.schema.json").read_text(encoding="utf-8"))
return Draft202012Validator(schema, format_checker=FormatChecker())
_VALIDATORS = {v: _validator(v) for v in SUPPORTED_VERSIONS}
def canonical_host(host: str) -> str:
@ -98,7 +112,9 @@ def canonical_host(host: str) -> str:
def schema_findings(doc) -> list[Finding]:
out = []
for err in sorted(_VALIDATOR.iter_errors(doc), key=lambda e: list(e.absolute_path)):
version = doc.get("mas_version") if isinstance(doc, dict) else None
validator = _VALIDATORS.get(version, _VALIDATORS["0.1"]) # unknown version: v0.1 reports it
for err in sorted(validator.iter_errors(doc), key=lambda e: list(e.absolute_path)):
path = "/" + "/".join(str(p) for p in err.absolute_path)
out.append(Finding(SCHEMA, ERROR, "schema-" + str(err.validator), path, err.message))
return out
@ -215,6 +231,10 @@ def build_statute(doc: Mapping, host: Optional[str] = None,
addresses=ro(doc.get("addresses", {})),
inherits=doc.get("inherits"),
unicode_local_part=doc.get("unicode_local_part", False),
mas_version=doc["mas_version"],
alt_resource=doc.get("alternate_syntax", {}).get("resource", False),
alt_interaction=doc.get("alternate_syntax", {}).get("interaction", False),
resource_id_case=doc["equivalence"].get("resource_id_case", "exact"),
document=MappingProxyType(dict(doc)),
)
@ -223,15 +243,8 @@ def merge_defaults(child: Statute, parent: Statute, parent_url: str) -> Statute:
"""Defaults-only inheritance: child wins; address registry never inherits."""
rt = {**parent.resource_types, **child.resource_types}
im = {**parent.interaction_modes, **child.interaction_modes}
return Statute(
namespace=child.namespace, p2=child.p2,
resource_types=MappingProxyType(rt), interaction_modes=MappingProxyType(im),
local_part_case=child.local_part_case, dot_folding=child.dot_folding,
addresses=child.addresses, inherits=child.inherits,
unicode_local_part=child.unicode_local_part,
inherited_from=(parent_url,) + parent.inherited_from,
document=child.document,
)
return replace(child, resource_types=MappingProxyType(rt), interaction_modes=MappingProxyType(im),
inherited_from=(parent_url,) + parent.inherited_from)
class InheritanceCycle(ValueError):

View file

@ -59,7 +59,7 @@ def test_no_parent_walk():
({U: OSError("connection refused")}, FailureKind.TransportFailure),
({U: ok(GS, "text/html")}, FailureKind.InvalidContentType),
({U: Response(200, JSON, b"{not json")}, FailureKind.NotJson),
({U: ok({**GS, "mas_version": "0.2"})}, FailureKind.UnsupportedVersion),
({U: ok({**GS, "mas_version": "0.3"})}, FailureKind.UnsupportedVersion),
({U: ok({**GS, "profiles": {"p1": True}})}, FailureKind.SchemaInvalid),
({U: ok(statute_document("semantic-p2-disabled-modes.json"))}, FailureKind.SemanticInvalid),
({U: ok({**GS, "namespace": "other.example"})}, FailureKind.NamespaceMismatch),

View file

@ -132,3 +132,9 @@ def test_received_quoted_non_mas_stays_invalid(raw):
def test_canonical_parser_still_rejects_quotes():
with pytest.raises(MasSyntaxError):
parse_address('"ticket=PR42"@example.com')
def test_vendored_v02_schema_matches_draft_bundle():
a = (ROOT / "mas-v0.2-spec/schema/mas-statute-v0.2.schema.json").read_bytes()
b = (ROOT / "mas_core/schema/mas-statute-v0.2.schema.json").read_bytes()
assert a == b

View file

@ -87,3 +87,26 @@ def test_intake_for_lint():
assert "intake-unknown-type" not in _with_address({"kind": "function", "intake_for": "ticket"})
assert _with_address({"kind": "function", "intake_for": "invoice"})["intake-unknown-type"] == ERROR
assert _with_address({"kind": "group", "intake_for": "ticket"})["intake-not-function"] == ERROR
def _v02(**extra):
doc = statute_document("valid-great-service.json")
doc["mas_version"] = "0.2"
doc.update(extra)
return doc
def test_v02_statute_builds_with_new_fields():
doc = _v02(alternate_syntax={"resource": True, "interaction": True})
doc["equivalence"]["resource_id_case"] = "insensitive"
st = build_statute(doc, "great-service.example")
assert (st.mas_version, st.alt_resource, st.alt_interaction, st.resource_id_case) == \
("0.2", True, True, "insensitive")
plain = build_statute(_v02(), "great-service.example")
assert (plain.alt_resource, plain.alt_interaction, plain.resource_id_case) == (False, False, "exact")
def test_v01_statute_rejects_v02_fields():
doc = statute_document("valid-great-service.json")
doc["alternate_syntax"] = {"resource": True}
assert any(f.severity == ERROR and f.layer == "schema" for f in lint(doc, "great-service.example"))

View file

@ -223,7 +223,7 @@ statute and the case-insensitive ticket service. Then re-run a smoke test
```task
id: MAS-WP-0005-T07
status: progress
status: done
priority: medium
```
@ -234,6 +234,23 @@ integration section) and the SCP-7 statute field
`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