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:
parent
3709fbb54b
commit
a12e1c4370
17 changed files with 1814 additions and 20 deletions
55
mas-v0.2-spec/CHANGELOG.md
Normal file
55
mas-v0.2-spec/CHANGELOG.md
Normal 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.
|
||||
83
mas-v0.2-spec/CONFORMANCE.md
Normal file
83
mas-v0.2-spec/CONFORMANCE.md
Normal 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
32
mas-v0.2-spec/README.md
Normal 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.
|
||||
589
mas-v0.2-spec/SPECIFICATION.md
Normal file
589
mas-v0.2-spec/SPECIFICATION.md
Normal 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.**
|
||||
354
mas-v0.2-spec/conformance/addresses.json
Normal file
354
mas-v0.2-spec/conformance/addresses.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
59
mas-v0.2-spec/examples/mas-statute.example.json
Normal file
59
mas-v0.2-spec/examples/mas-statute.example.json
Normal 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
|
||||
}
|
||||
}
|
||||
62
mas-v0.2-spec/grammar/mas-v0.2.abnf
Normal file
62
mas-v0.2-spec/grammar/mas-v0.2.abnf
Normal 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.
|
||||
130
mas-v0.2-spec/reference/mas_parser.py
Executable file
130
mas-v0.2-spec/reference/mas_parser.py
Executable 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()
|
||||
185
mas-v0.2-spec/schema/mas-statute-v0.2.schema.json
Normal file
185
mas-v0.2-spec/schema/mas-statute-v0.2.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"mas_version": "0.2",
|
||||
"mas_version": "0.3",
|
||||
"namespace": "great-service.example",
|
||||
"profiles": {
|
||||
"p1": true,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
185
mas_core/schema/mas-statute-v0.2.schema.json
Normal file
185
mas_core/schema/mas-statute-v0.2.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue