From c4719a238d66f273efa958fed31ad2ea5718e8f0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 09:31:30 +0200 Subject: [PATCH] MAS-WP-0008: v0.2 wording pass, statute-aware conformance corpus in the bundle conformance/statute-aware.json (vendored as mas_conformance/statute_aware.json) covers the 6.3 stand-in table, resource_id_case equivalence and the 12.1 destination results and refusals; mas test runs it. Misread refusals now name the field that would change. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd --- mas-v0.2-spec/CHANGELOG.md | 5 + mas-v0.2-spec/CONFORMANCE.md | 34 +- mas-v0.2-spec/README.md | 5 +- mas-v0.2-spec/SPECIFICATION.md | 50 ++- mas-v0.2-spec/conformance/statute-aware.json | 426 +++++++++++++++++++ mas_cli/main.py | 8 +- mas_conformance/__init__.py | 9 +- mas_conformance/runner.py | 44 +- mas_conformance/statute_aware.json | 426 +++++++++++++++++++ mas_core/generator.py | 9 +- tests/test_parser_conformance.py | 13 + 11 files changed, 988 insertions(+), 41 deletions(-) create mode 100644 mas-v0.2-spec/conformance/statute-aware.json create mode 100644 mas_conformance/statute_aware.json diff --git a/mas-v0.2-spec/CHANGELOG.md b/mas-v0.2-spec/CHANGELOG.md index 6f7c595..3a203bf 100644 --- a/mas-v0.2-spec/CHANGELOG.md +++ b/mas-v0.2-spec/CHANGELOG.md @@ -24,6 +24,11 @@ New features, opt-in per statute with `"mas_version": "0.2"` unquoted form. Receivers treat them identically; generators emit the unquoted form; canonical parsers still reject the quoted form (§4). Fixture p1-040 note updated. +- **Conformance (MAS-WP-0008).** `conformance/statute-aware.json` and + the `MAS-V0.2-STATUTE-AWARE` surface cover the stand-in table, the + `resource_id_case` equivalence and destination-transform refusals. + The text inherited from v0.1.1 no longer says "v0.1" for rules that are + unchanged. ## v0.1.1 (errata) diff --git a/mas-v0.2-spec/CONFORMANCE.md b/mas-v0.2-spec/CONFORMANCE.md index b717a84..c6b8bf5 100644 --- a/mas-v0.2-spec/CONFORMANCE.md +++ b/mas-v0.2-spec/CONFORMANCE.md @@ -1,4 +1,4 @@ -# MAS v0.1 Conformance +# MAS v0.2 Conformance (draft) ## 1. Test surfaces @@ -6,8 +6,9 @@ An implementation can claim any of the following independently: - `MAS-P1-PARSER` - `MAS-P2-PARSER` -- `MAS-V0.1-GENERATOR` -- `MAS-V0.1-STATUTE` +- `MAS-V0.2-GENERATOR` +- `MAS-V0.2-STATUTE` +- `MAS-V0.2-STATUTE-AWARE` ## 2. Parser requirements @@ -30,7 +31,7 @@ A generator: - MUST emit `+` before `!`; - MUST NOT emit more than one `+` or one `!`; - MUST NOT emit `$` or `^`; -- MUST NOT emit both `+` and `!` in v0.1; +- MUST NOT emit both `+` and `!`; - 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, @@ -39,7 +40,8 @@ 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`. +`schema/mas-statute-v0.2.schema.json` when `mas_version` is `0.2`. A `0.1` +statute is validated with the v0.1.1 schema and gets no v0.2 features. It MUST NOT apply: @@ -51,7 +53,23 @@ It MUST NOT apply: unless the exact-host statute successfully validates and declares them. -## 5. Discovery failure +## 5. Statute-aware requirements (v0.2) + +An implementation claiming `MAS-V0.2-STATUTE-AWARE` MUST produce the +expected result for every case in `conformance/statute-aware.json`. Each +case names one of the file's inline statutes, whose `namespace` is the host. + +- `interpretation`: the received local-part is read as `canonical`, using + the listed `stand_ins` (§6.3). The parser alone never interprets + stand-ins. +- `equivalence`: `a` and `b` have the same equivalence key exactly when + `equivalent` is true (§8, `resource_id_case`). +- `destination`: applying the transforms in `needs` to `local_part` yields + `expect`. When `expect` is `null`, the generator MUST refuse (§12.1). + `reason` is informative, and refusal codes are not normative. + `allow_lossy` marks an explicit request for a drop transform. + +## 6. Discovery failure Failure to retrieve or validate a statute: @@ -59,7 +77,7 @@ Failure to retrieve or validate a statute: - does invalidate any claim that MAS namespace semantics were discovered; - falls back to ordinary email handling plus exact local configuration. -## 6. Inheritance +## 7. Inheritance Implementations are not required to implement `inherits`. @@ -71,7 +89,7 @@ Implementations that do implement it MUST: - never inherit explicit `addresses` registry entries; - let the child override inherited resource types and interaction modes. -## 7. Test command +## 8. Test command The bundled reference parser can be run with: diff --git a/mas-v0.2-spec/README.md b/mas-v0.2-spec/README.md index b779e5a..1f4206a 100644 --- a/mas-v0.2-spec/README.md +++ b/mas-v0.2-spec/README.md @@ -11,17 +11,18 @@ This bundle is the **draft** of MAS v0.2. It extends v0.1.1 (see `CHANGELOG.md`) - `examples/mas-statute.example.json` — example statute - `CONFORMANCE.md` — parser/generator/statute conformance rules - `conformance/addresses.json` — positive and negative address fixtures +- `conformance/statute-aware.json` — v0.2 stand-in, equivalence and destination-transform cases - `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 +- **P3 / Economic:** reserved only; `$` and `^` have no v0.2 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. +grammar. ## Discovery status diff --git a/mas-v0.2-spec/SPECIFICATION.md b/mas-v0.2-spec/SPECIFICATION.md index 3978d50..1910862 100644 --- a/mas-v0.2-spec/SPECIFICATION.md +++ b/mas-v0.2-spec/SPECIFICATION.md @@ -20,7 +20,7 @@ contractual commitments. ## 2. Scope -MAS v0.1 standardizes: +MAS standardizes: 1. non-structural naming hints for people, actors, and functions; 2. structured resource targets using `=`; @@ -28,15 +28,16 @@ MAS v0.1 standardizes: 4. an optional interaction profile using `!`; 5. exact-host statute discovery; 6. conservative equivalence behavior; -7. parser and generator conformance. +7. parser and generator conformance; +8. statute-enabled stand-ins and destination transforms (v0.2). -`$` and `^` are reserved. They have no MAS v0.1 meaning. +`$` and `^` are reserved. They have no MAS v0.2 meaning. ## 3. Profiles ### 3.1 Profile P1 — Naming + Resource -P1 is the base v0.1 profile. +P1 is the base profile. - `.` MAY be used as a person-style naming separator. - `-` MAY be used as an actor-style naming separator. @@ -58,22 +59,22 @@ constitute acceptance. Acceptance is external to the address. ### 3.3 Profile P3 — Economic -P3 is not defined in v0.1. +P3 is not defined in v0.2. `$` and `^` are reserved so that later profiles may define consideration and -conditions without creating v0.1 parsing ambiguity. +conditions without creating parsing ambiguity. ## 4. Canonical ASCII local-part -The normative v0.1 machine grammar is the ABNF in +The normative 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 +Canonical MAS local-parts are unquoted ASCII dot-atom-like strings. +Quoted local-parts and SMTPUTF8 local-parts are outside the canonical 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 +as ordinary email, but MUST NOT claim that they are canonical MAS addresses. A quoted local-part whose content, after removing the quotes and with no @@ -144,7 +145,7 @@ 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 +A generator MUST NOT emit both `+` and `!` in the same generated local-part. Implementations requiring both semantics SHOULD move one of them to external metadata. @@ -153,15 +154,16 @@ while retaining one canonical parse. ### 4.5 Reserved characters -Within canonical MAS v0.1 structured values: +Within canonical MAS structured values: + ! $ ^ are reserved. -MAS v0.1 defines no escaping mechanism. +MAS defines no escaping mechanism. The stand-ins (§6.3) are alternate +forms enabled by a statute, not escapes. -A percent-looking sequence such as `%2B` is ordinary data in v0.1 and MUST NOT +A percent-looking sequence such as `%2B` is ordinary data and MUST NOT be percent-decoded by the MAS parser. ## 5. Naming semantics @@ -339,15 +341,15 @@ 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 +SMTPUTF8 allows non-ASCII local-parts, but MAS v0.2 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 +extensions. Such addresses MUST NOT be advertised as canonical MAS machine syntax. A future MAS version may define NFC normalization and Unicode comparison -rules. v0.1 deliberately avoids doing so. +rules. v0.2 deliberately avoids doing so. ## 10. Statute discovery @@ -383,7 +385,9 @@ 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; +4. requires a document conforming to the statute schema for its + `mas_version` (`schema/mas-statute-v0.2.schema.json` for `0.2`; a `0.1` + statute is validated and interpreted under v0.1.1); 5. verifies that `namespace` equals the queried host after normal domain-name canonicalization; 6. otherwise treats discovery as failed. @@ -398,13 +402,13 @@ 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. +mechanism and is not defined in v0.2. ### 10.4 Inheritance No parent-domain inheritance is implicit. -A child statute MAY contain an `inherits` URI. v0.1 inheritance is +A child statute MAY contain an `inherits` URI. Inheritance is **defaults-only**: - the parent document is fetched and validated independently; @@ -510,7 +514,7 @@ 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 +Because the budget is small, MAS permits at most one facet and one interaction, and generators MUST NOT emit both simultaneously. ## 14. Security @@ -522,7 +526,7 @@ interaction, and generators MUST NOT emit both simultaneously. 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. +7. `$` and `^` MUST NOT be rendered as payment/contract semantics in v0.2. 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. @@ -558,7 +562,7 @@ A generator: - emits no reserved operator within a value; - emits at most one `+`; - emits at most one `!`; -- MUST NOT emit both `+` and `!` in v0.1; +- MUST NOT emit both `+` and `!`; - enforces the SMTP local-part octet limit. ### 15.4 Statute-aware implementation diff --git a/mas-v0.2-spec/conformance/statute-aware.json b/mas-v0.2-spec/conformance/statute-aware.json new file mode 100644 index 0000000..789ac4e --- /dev/null +++ b/mas-v0.2-spec/conformance/statute-aware.json @@ -0,0 +1,426 @@ +{ + "description": "MAS v0.2 statute-aware cases: stand-in interpretation (6.3), resource_id_case equivalence (8), destination-transform results and refusals (12.1). Each case names a statute in 'statutes'; the host is the statute's namespace.", + "statutes": { + "stand-ins": { + "mas_version": "0.2", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false, + "resource_id_case": "insensitive" + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + }, + "alternate_syntax": { + "resource": true, + "interaction": true + } + }, + "stand-ins-exact-ids": { + "mas_version": "0.2", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false, + "resource_id_case": "exact" + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + }, + "alternate_syntax": { + "resource": true, + "interaction": true + } + }, + "no-stand-ins": { + "mas_version": "0.2", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false, + "resource_id_case": "exact" + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + }, + "alternate_syntax": { + "resource": false, + "interaction": false + } + }, + "v0.1": { + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + } + } + }, + "interpretation": [ + { + "id": "si-001", + "statute": "stand-ins", + "local_part": "ticket---callback", + "canonical": "ticket!callback", + "stand_ins": [ + "interaction" + ] + }, + { + "id": "si-002", + "statute": "stand-ins", + "local_part": "ticket--gs1---callback", + "canonical": "ticket=gs1!callback", + "stand_ins": [ + "interaction", + "resource" + ] + }, + { + "id": "si-003", + "statute": "stand-ins", + "local_part": "ticket--a---b", + "canonical": "ticket=a---b", + "stand_ins": [ + "resource" + ], + "note": "b is not an enabled mode" + }, + { + "id": "si-004", + "statute": "stand-ins", + "local_part": "ticket--gs1+comments", + "canonical": "ticket=gs1+comments", + "stand_ins": [ + "resource" + ] + }, + { + "id": "si-005", + "statute": "stand-ins", + "local_part": "support---callback", + "canonical": "support!callback", + "stand_ins": [ + "interaction" + ] + }, + { + "id": "si-006", + "statute": "stand-ins", + "local_part": "invoice--x1", + "canonical": "invoice--x1", + "stand_ins": [], + "note": "undeclared resource type" + }, + { + "id": "si-007", + "statute": "stand-ins", + "local_part": "ticket=gs1!callback", + "canonical": "ticket=gs1!callback", + "stand_ins": [], + "note": "already canonical" + }, + { + "id": "si-008", + "statute": "no-stand-ins", + "local_part": "ticket--gs1", + "canonical": "ticket--gs1", + "stand_ins": [], + "note": "switch off" + }, + { + "id": "si-009", + "statute": "no-stand-ins", + "local_part": "ticket---callback", + "canonical": "ticket---callback", + "stand_ins": [] + }, + { + "id": "si-010", + "statute": "v0.1", + "local_part": "ticket--gs1", + "canonical": "ticket--gs1", + "stand_ins": [], + "note": "v0.1 statute has no stand-ins" + } + ], + "equivalence": [ + { + "id": "eq-001", + "statute": "stand-ins", + "a": "ticket=GS-1", + "b": "ticket=gs-1", + "equivalent": true, + "note": "resource_id_case insensitive" + }, + { + "id": "eq-002", + "statute": "stand-ins", + "a": "ticket--GS-85QJ8WC09P", + "b": "ticket=gs-85qj8wc09p", + "equivalent": true, + "note": "stand-in + insensitive id" + }, + { + "id": "eq-003", + "statute": "stand-ins-exact-ids", + "a": "ticket=GS-1", + "b": "ticket=gs-1", + "equivalent": false, + "note": "resource_id_case exact" + }, + { + "id": "eq-004", + "statute": "stand-ins", + "a": "Support", + "b": "support", + "equivalent": false, + "note": "only resource ids fold; local_part_case exact" + }, + { + "id": "eq-005", + "statute": "stand-ins", + "a": "ticket=gs-1+comments", + "b": "ticket=gs-1", + "equivalent": true, + "note": "facet does not change the target" + } + ], + "destination": [ + { + "id": "dt-001", + "statute": "stand-ins", + "local_part": "ticket=GS-1+comments", + "needs": [], + "expect": "ticket=GS-1+comments" + }, + { + "id": "dt-002", + "statute": "stand-ins", + "local_part": "ticket=GS-85QJ8WC09P+comments", + "needs": [ + "resource-alternate", + "lowercase" + ], + "expect": "ticket--gs-85qj8wc09p+comments", + "note": "IONOS forwarding form" + }, + { + "id": "dt-003", + "statute": "stand-ins", + "local_part": "ticket=GS-85QJ8WC09P+comments", + "needs": [ + "lowercase" + ], + "expect": "ticket=gs-85qj8wc09p+comments", + "note": "Google Groups" + }, + { + "id": "dt-004", + "statute": "stand-ins", + "local_part": "ticket!callback", + "needs": [ + "interaction-alternate" + ], + "expect": "ticket---callback" + }, + { + "id": "dt-005", + "statute": "stand-ins", + "local_part": "support", + "needs": [ + "resource-alternate", + "lowercase" + ], + "expect": "support", + "note": "transforms apply only where they change something" + }, + { + "id": "dt-006", + "statute": "stand-ins", + "local_part": "ticket!callback", + "needs": [ + "drop-interaction" + ], + "allow_lossy": true, + "expect": "ticket" + }, + { + "id": "dt-101", + "statute": "no-stand-ins", + "local_part": "ticket=GS-1", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "statute does not enable the stand-in" + }, + { + "id": "dt-102", + "statute": "v0.1", + "local_part": "ticket=GS-1", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "v0.1 statute" + }, + { + "id": "dt-103", + "statute": "stand-ins-exact-ids", + "local_part": "ticket=GS-1", + "needs": [ + "lowercase" + ], + "expect": null, + "reason": "lower-casing changes an exact id" + }, + { + "id": "dt-104", + "statute": "stand-ins", + "local_part": "ticket=RE=2026-4711", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "id contains '='; TYPE--ID would split at the first --" + }, + { + "id": "dt-105", + "statute": "stand-ins", + "local_part": "ticket=gs-1+Comments", + "needs": [ + "lowercase" + ], + "expect": null, + "reason": "facet case would change" + }, + { + "id": "dt-106", + "statute": "stand-ins", + "local_part": "ticket=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "65 octets after the transform" + }, + { + "id": "dt-107", + "statute": "stand-ins", + "local_part": "ticket=gs-1+comments", + "needs": [ + "drop-facet" + ], + "expect": null, + "reason": "lossy transform without explicit request" + }, + { + "id": "dt-108", + "statute": "stand-ins", + "local_part": "ticket=gs-1!chat", + "needs": [ + "interaction-alternate" + ], + "expect": null, + "reason": "mode not enabled by the statute" + } + ] +} diff --git a/mas_cli/main.py b/mas_cli/main.py index a9dc12d..661dd7b 100644 --- a/mas_cli/main.py +++ b/mas_cli/main.py @@ -220,8 +220,9 @@ def cmd_lint(args): def cmd_test(args): from mas_conformance import statute_cases, statute_document - from mas_conformance.runner import run_parser_corpus + from mas_conformance.runner import run_parser_corpus, run_statute_aware_corpus rep = run_parser_corpus() + aware = run_statute_aware_corpus() st_fail = [] for c in statute_cases(): fs = lint(statute_document(c["file"]), c["host"]) @@ -238,7 +239,10 @@ def cmd_test(args): print(f"statute corpus: {n_st - len(st_fail)}/{n_st} passed") for fid, file, why in st_fail: print(f" FAIL {fid} {file}: {why}") - return 0 if rep.ok and not st_fail else 1 + print(f"v0.2 statute-aware corpus: {aware.passed}/{aware.passed + len(aware.failures)} passed") + for fid, subject, why in aware.failures: + print(f" FAIL {fid} {subject!r}: {why}") + return 0 if rep.ok and not st_fail and aware.ok else 1 def build_parser(): diff --git a/mas_conformance/__init__.py b/mas_conformance/__init__.py index 61597a7..cb3c67a 100644 --- a/mas_conformance/__init__.py +++ b/mas_conformance/__init__.py @@ -3,7 +3,9 @@ `addresses.json` is vendored unchanged from the spec bundle (`mas-v0.1-spec/conformance/addresses.json`) and is normative. `properties.json` adds implementation-independent property and boundary -cases. `statutes/` holds statute validation cases. Every language +cases. `statutes/` holds statute validation cases. `statute_aware.json` is +vendored unchanged from the v0.2 draft bundle +(`mas-v0.2-spec/conformance/statute-aware.json`). Every language implementation should consume these files unchanged. """ @@ -32,3 +34,8 @@ def statute_cases() -> list[dict]: def statute_document(file: str) -> dict: return _load(f"statutes/{file}") + + +def statute_aware_fixtures() -> dict: + """v0.2: {statutes, interpretation, equivalence, destination}.""" + return _load("statute_aware.json") diff --git a/mas_conformance/runner.py b/mas_conformance/runner.py index d7bdfff..656eacc 100644 --- a/mas_conformance/runner.py +++ b/mas_conformance/runner.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from mas_core.parser import MasSyntaxError, parse_for_profile -from . import address_fixtures, property_fixtures +from . import address_fixtures, property_fixtures, statute_aware_fixtures @dataclass @@ -48,3 +48,45 @@ def run_parser_corpus() -> Report: else: report.passed += 1 return report + + +def run_statute_aware_corpus() -> Report: + """v0.2 stand-ins, resource_id_case equivalence and destination transforms.""" + from mas_core.generator import CannotExpress, for_destination + from mas_core.parser import parse_address + from mas_core.policy import resolve + from mas_core.statute import build_statute + + fx = statute_aware_fixtures() + statutes = {k: build_statute(doc, doc["namespace"]) for k, doc in fx["statutes"].items()} + + def sem(st, lp): + return resolve(parse_address(f"{lp}@{st.namespace}"), st) + + report = Report() + + def record(f, subject, problem): + if problem: + report.failures.append((f["id"], subject, problem)) + else: + report.passed += 1 + + for f in fx["interpretation"]: + d = sem(statutes[f["statute"]], f["local_part"]).to_dict() + got = (d["canonical_local_part"], d["stand_ins"]) + record(f, f["local_part"], None if got == (f["canonical"], f["stand_ins"]) + else f"got {got}, expected {(f['canonical'], f['stand_ins'])}") + for f in fx["equivalence"]: + st = statutes[f["statute"]] + same = sem(st, f["a"]).equivalence_key == sem(st, f["b"]).equivalence_key + record(f, f"{f['a']} ~ {f['b']}", None if same == f["equivalent"] + else f"equivalent={same}, expected {f['equivalent']}") + for f in fx["destination"]: + try: + got = for_destination(f["local_part"], statutes[f["statute"]], f["needs"], + allow_lossy=f.get("allow_lossy", False)).local_part + except CannotExpress as e: + got, why = None, e.code + record(f, f["local_part"], None if got == f["expect"] + else f"got {got!r}" + (f" ({why})" if got is None else "") + f", expected {f['expect']!r}") + return report diff --git a/mas_conformance/statute_aware.json b/mas_conformance/statute_aware.json new file mode 100644 index 0000000..789ac4e --- /dev/null +++ b/mas_conformance/statute_aware.json @@ -0,0 +1,426 @@ +{ + "description": "MAS v0.2 statute-aware cases: stand-in interpretation (6.3), resource_id_case equivalence (8), destination-transform results and refusals (12.1). Each case names a statute in 'statutes'; the host is the statute's namespace.", + "statutes": { + "stand-ins": { + "mas_version": "0.2", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false, + "resource_id_case": "insensitive" + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + }, + "alternate_syntax": { + "resource": true, + "interaction": true + } + }, + "stand-ins-exact-ids": { + "mas_version": "0.2", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false, + "resource_id_case": "exact" + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + }, + "alternate_syntax": { + "resource": true, + "interaction": true + } + }, + "no-stand-ins": { + "mas_version": "0.2", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false, + "resource_id_case": "exact" + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + }, + "alternate_syntax": { + "resource": false, + "interaction": false + } + }, + "v0.1": { + "mas_version": "0.1", + "namespace": "great-service.example", + "profiles": { + "p1": true, + "p2": true + }, + "resource_types": { + "ticket": { + "enabled": true, + "description": "Customer-service ticket", + "identifier_policy": "non-guessable-preferred" + } + }, + "interaction_modes": { + "callback": { + "enabled": true, + "description": "Continue by a voice callback" + } + }, + "equivalence": { + "local_part_case": "exact", + "dot_folding": false + }, + "addresses": { + "support": { + "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" + } + } + } + }, + "interpretation": [ + { + "id": "si-001", + "statute": "stand-ins", + "local_part": "ticket---callback", + "canonical": "ticket!callback", + "stand_ins": [ + "interaction" + ] + }, + { + "id": "si-002", + "statute": "stand-ins", + "local_part": "ticket--gs1---callback", + "canonical": "ticket=gs1!callback", + "stand_ins": [ + "interaction", + "resource" + ] + }, + { + "id": "si-003", + "statute": "stand-ins", + "local_part": "ticket--a---b", + "canonical": "ticket=a---b", + "stand_ins": [ + "resource" + ], + "note": "b is not an enabled mode" + }, + { + "id": "si-004", + "statute": "stand-ins", + "local_part": "ticket--gs1+comments", + "canonical": "ticket=gs1+comments", + "stand_ins": [ + "resource" + ] + }, + { + "id": "si-005", + "statute": "stand-ins", + "local_part": "support---callback", + "canonical": "support!callback", + "stand_ins": [ + "interaction" + ] + }, + { + "id": "si-006", + "statute": "stand-ins", + "local_part": "invoice--x1", + "canonical": "invoice--x1", + "stand_ins": [], + "note": "undeclared resource type" + }, + { + "id": "si-007", + "statute": "stand-ins", + "local_part": "ticket=gs1!callback", + "canonical": "ticket=gs1!callback", + "stand_ins": [], + "note": "already canonical" + }, + { + "id": "si-008", + "statute": "no-stand-ins", + "local_part": "ticket--gs1", + "canonical": "ticket--gs1", + "stand_ins": [], + "note": "switch off" + }, + { + "id": "si-009", + "statute": "no-stand-ins", + "local_part": "ticket---callback", + "canonical": "ticket---callback", + "stand_ins": [] + }, + { + "id": "si-010", + "statute": "v0.1", + "local_part": "ticket--gs1", + "canonical": "ticket--gs1", + "stand_ins": [], + "note": "v0.1 statute has no stand-ins" + } + ], + "equivalence": [ + { + "id": "eq-001", + "statute": "stand-ins", + "a": "ticket=GS-1", + "b": "ticket=gs-1", + "equivalent": true, + "note": "resource_id_case insensitive" + }, + { + "id": "eq-002", + "statute": "stand-ins", + "a": "ticket--GS-85QJ8WC09P", + "b": "ticket=gs-85qj8wc09p", + "equivalent": true, + "note": "stand-in + insensitive id" + }, + { + "id": "eq-003", + "statute": "stand-ins-exact-ids", + "a": "ticket=GS-1", + "b": "ticket=gs-1", + "equivalent": false, + "note": "resource_id_case exact" + }, + { + "id": "eq-004", + "statute": "stand-ins", + "a": "Support", + "b": "support", + "equivalent": false, + "note": "only resource ids fold; local_part_case exact" + }, + { + "id": "eq-005", + "statute": "stand-ins", + "a": "ticket=gs-1+comments", + "b": "ticket=gs-1", + "equivalent": true, + "note": "facet does not change the target" + } + ], + "destination": [ + { + "id": "dt-001", + "statute": "stand-ins", + "local_part": "ticket=GS-1+comments", + "needs": [], + "expect": "ticket=GS-1+comments" + }, + { + "id": "dt-002", + "statute": "stand-ins", + "local_part": "ticket=GS-85QJ8WC09P+comments", + "needs": [ + "resource-alternate", + "lowercase" + ], + "expect": "ticket--gs-85qj8wc09p+comments", + "note": "IONOS forwarding form" + }, + { + "id": "dt-003", + "statute": "stand-ins", + "local_part": "ticket=GS-85QJ8WC09P+comments", + "needs": [ + "lowercase" + ], + "expect": "ticket=gs-85qj8wc09p+comments", + "note": "Google Groups" + }, + { + "id": "dt-004", + "statute": "stand-ins", + "local_part": "ticket!callback", + "needs": [ + "interaction-alternate" + ], + "expect": "ticket---callback" + }, + { + "id": "dt-005", + "statute": "stand-ins", + "local_part": "support", + "needs": [ + "resource-alternate", + "lowercase" + ], + "expect": "support", + "note": "transforms apply only where they change something" + }, + { + "id": "dt-006", + "statute": "stand-ins", + "local_part": "ticket!callback", + "needs": [ + "drop-interaction" + ], + "allow_lossy": true, + "expect": "ticket" + }, + { + "id": "dt-101", + "statute": "no-stand-ins", + "local_part": "ticket=GS-1", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "statute does not enable the stand-in" + }, + { + "id": "dt-102", + "statute": "v0.1", + "local_part": "ticket=GS-1", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "v0.1 statute" + }, + { + "id": "dt-103", + "statute": "stand-ins-exact-ids", + "local_part": "ticket=GS-1", + "needs": [ + "lowercase" + ], + "expect": null, + "reason": "lower-casing changes an exact id" + }, + { + "id": "dt-104", + "statute": "stand-ins", + "local_part": "ticket=RE=2026-4711", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "id contains '='; TYPE--ID would split at the first --" + }, + { + "id": "dt-105", + "statute": "stand-ins", + "local_part": "ticket=gs-1+Comments", + "needs": [ + "lowercase" + ], + "expect": null, + "reason": "facet case would change" + }, + { + "id": "dt-106", + "statute": "stand-ins", + "local_part": "ticket=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "needs": [ + "resource-alternate" + ], + "expect": null, + "reason": "65 octets after the transform" + }, + { + "id": "dt-107", + "statute": "stand-ins", + "local_part": "ticket=gs-1+comments", + "needs": [ + "drop-facet" + ], + "expect": null, + "reason": "lossy transform without explicit request" + }, + { + "id": "dt-108", + "statute": "stand-ins", + "local_part": "ticket=gs-1!chat", + "needs": [ + "interaction-alternate" + ], + "expect": null, + "reason": "mode not enabled by the statute" + } + ] +} diff --git a/mas_core/generator.py b/mas_core/generator.py index 6c80d2d..599a7a4 100644 --- a/mas_core/generator.py +++ b/mas_core/generator.py @@ -128,8 +128,9 @@ def for_destination(local_part: str, statute: "Statute", needs: Iterable[str] = expected = resolve(ParsedLocalPart(raw=_render(target, facet, interaction), target=target, facet=facet, interaction=interaction), statute) got = resolve(out, statute) - if (got.equivalence_key, got.facet, got.interaction) != \ - (expected.equivalence_key, expected.facet, expected.interaction): - raise CannotExpress("would-be-misread", - f"{text!r} would be read as {got.to_dict()['canonical_local_part']!r}") + fields = ("equivalence_key", "facet", "interaction") + diff = [f"{k} {getattr(got, k)!r} instead of {getattr(expected, k)!r}" + for k in fields if getattr(got, k) != getattr(expected, k)] + if diff: + raise CannotExpress("would-be-misread", f"{text!r} would be read with " + ", ".join(diff)) return DestinationAddress(text, tuple(applied), tuple(warnings)) diff --git a/tests/test_parser_conformance.py b/tests/test_parser_conformance.py index cc5dcd4..cc7b85a 100644 --- a/tests/test_parser_conformance.py +++ b/tests/test_parser_conformance.py @@ -138,3 +138,16 @@ 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 + + +def test_vendored_v02_statute_aware_corpus_matches_draft_bundle(): + a = (ROOT / "mas-v0.2-spec/conformance/statute-aware.json").read_bytes() + b = (ROOT / "mas_conformance/statute_aware.json").read_bytes() + assert a == b + + +def test_v02_statute_aware_corpus(): + from mas_conformance.runner import run_statute_aware_corpus + rep = run_statute_aware_corpus() + assert rep.ok, rep.failures + assert rep.passed == 29