diff --git a/mas-v0.2-spec/CHANGELOG.md b/mas-v0.2-spec/CHANGELOG.md new file mode 100644 index 0000000..6f7c595 --- /dev/null +++ b/mas-v0.2-spec/CHANGELOG.md @@ -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. diff --git a/mas-v0.2-spec/CONFORMANCE.md b/mas-v0.2-spec/CONFORMANCE.md new file mode 100644 index 0000000..b717a84 --- /dev/null +++ b/mas-v0.2-spec/CONFORMANCE.md @@ -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. diff --git a/mas-v0.2-spec/README.md b/mas-v0.2-spec/README.md new file mode 100644 index 0000000..b779e5a --- /dev/null +++ b/mas-v0.2-spec/README.md @@ -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. diff --git a/mas-v0.2-spec/SPECIFICATION.md b/mas-v0.2-spec/SPECIFICATION.md new file mode 100644 index 0000000..3978d50 --- /dev/null +++ b/mas-v0.2-spec/SPECIFICATION.md @@ -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.** diff --git a/mas-v0.2-spec/conformance/addresses.json b/mas-v0.2-spec/conformance/addresses.json new file mode 100644 index 0000000..8012efd --- /dev/null +++ b/mas-v0.2-spec/conformance/addresses.json @@ -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 + } + } +] diff --git a/mas-v0.2-spec/examples/mas-statute.example.json b/mas-v0.2-spec/examples/mas-statute.example.json new file mode 100644 index 0000000..59a407f --- /dev/null +++ b/mas-v0.2-spec/examples/mas-statute.example.json @@ -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 + } +} diff --git a/mas-v0.2-spec/grammar/mas-v0.2.abnf b/mas-v0.2-spec/grammar/mas-v0.2.abnf new file mode 100644 index 0000000..1418d83 --- /dev/null +++ b/mas-v0.2-spec/grammar/mas-v0.2.abnf @@ -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. diff --git a/mas-v0.2-spec/reference/mas_parser.py b/mas-v0.2-spec/reference/mas_parser.py new file mode 100755 index 0000000..c90f7ea --- /dev/null +++ b/mas-v0.2-spec/reference/mas_parser.py @@ -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() diff --git a/mas-v0.2-spec/schema/mas-statute-v0.2.schema.json b/mas-v0.2-spec/schema/mas-statute-v0.2.schema.json new file mode 100644 index 0000000..b860e99 --- /dev/null +++ b/mas-v0.2-spec/schema/mas-statute-v0.2.schema.json @@ -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 + } + } + } + } +} diff --git a/mas_conformance/statutes/schema-version.json b/mas_conformance/statutes/schema-version.json index 3f8e663..2c789de 100644 --- a/mas_conformance/statutes/schema-version.json +++ b/mas_conformance/statutes/schema-version.json @@ -1,5 +1,5 @@ { - "mas_version": "0.2", + "mas_version": "0.3", "namespace": "great-service.example", "profiles": { "p1": true, diff --git a/mas_core/discovery.py b/mas_core/discovery.py index 32b99a5..6245b25 100644 --- a/mas_core/discovery.py +++ b/mas_core/discovery.py @@ -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) diff --git a/mas_core/schema/mas-statute-v0.2.schema.json b/mas_core/schema/mas-statute-v0.2.schema.json new file mode 100644 index 0000000..b860e99 --- /dev/null +++ b/mas_core/schema/mas-statute-v0.2.schema.json @@ -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 + } + } + } + } +} diff --git a/mas_core/statute.py b/mas_core/statute.py index 1665775..b7b0fe2 100644 --- a/mas_core/statute.py +++ b/mas_core/statute.py @@ -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): diff --git a/tests/test_discovery.py b/tests/test_discovery.py index b2a29c0..ce1eaf4 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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), diff --git a/tests/test_parser_conformance.py b/tests/test_parser_conformance.py index 45b17e5..cc5dcd4 100644 --- a/tests/test_parser_conformance.py +++ b/tests/test_parser_conformance.py @@ -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 diff --git a/tests/test_statute.py b/tests/test_statute.py index f59bddc..60817cd 100644 --- a/tests/test_statute.py +++ b/tests/test_statute.py @@ -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")) diff --git a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md index 76b9cb5..8de1cc6 100644 --- a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md +++ b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md @@ -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