MAS-WP-0005-T02: SCP-2 lower-case resource types (spec v0.1.1)

Grammar, statute schema key pattern, parser (is_resource_type, also used
by statute lint), reference parser, spec 6.1 and a new CHANGELOG. Fixtures
p1-042..045; prop-case-001 Ticket=PR42 is now invalid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 252470@bnt-lap001
Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
This commit is contained in:
tegwick 2026-10-18 18:08:36 +02:00
parent 6ee9e4dd54
commit 5b57a9941e
14 changed files with 155 additions and 25 deletions

View file

@ -49,8 +49,11 @@ lower-case in the grammar.
**Options:**
- **(a) recommended:** restrict `resource-type` in the grammar to lower-case
ALPHA, DIGIT and `-`. `Ticket=PR42` then parses as an opaque target, which
is the safe outcome under §6.1 ("no semantics solely because of `=`").
ALPHA, DIGIT and `-`. (Correction during implementation: `Ticket=PR42`
does **not** become an opaque target, because opaque targets cannot
contain `=`. It becomes a non-canonical address, rejected with
`invalid-resource-type` and carried as ordinary email. That is stricter
and still safe under §6.1.)
- (b) keep the grammar and state in §8 that the statute's equivalence policy
also applies to the type name.

View file

@ -0,0 +1,17 @@
# MAS specification changelog
## v0.1.1 (errata, in progress)
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.
## v0.1
Initial draft.

View file

@ -4,7 +4,8 @@ This bundle turns the MAS v0.1 concept into an implementable draft.
## Contents
- `SPECIFICATION.md` — normative protocol/convention text
- `SPECIFICATION.md` — normative protocol/convention text (revision v0.1.1)
- `CHANGELOG.md` — errata applied since v0.1
- `grammar/mas-v0.1.abnf` — canonical ASCII local-part grammar
- `schema/mas-statute-v0.1.schema.json` — statute discovery document schema
- `examples/mas-statute.example.json` — example statute

View file

@ -2,7 +2,9 @@
## 1. Status and normative language
This document defines MAS v0.1.
This document defines MAS v0.1, revision v0.1.1. The errata it applies are
listed in `CHANGELOG.md`; they clarify or tighten v0.1 without changing what
a valid canonical v0.1.1 address means.
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**,
**RECOMMENDED**, **MAY**, and **OPTIONAL** are to be interpreted as described
@ -186,6 +188,15 @@ 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

View file

@ -311,5 +311,43 @@
"profile": "p1",
"valid": false,
"error": "non-ASCII outside canonical MAS v0.1"
},
{
"id": "p1-042",
"local_part": "Ticket=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): resource types are lower-case"
},
{
"id": "p1-043",
"local_part": "my--type=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): no doubled separator in resource types"
},
{
"id": "p1-044",
"local_part": "type-=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): no trailing separator"
},
{
"id": "p1-045",
"local_part": "support-case_v2=PR42",
"profile": "p1",
"valid": true,
"parsed": {
"target": "support-case_v2=PR42",
"target_kind": "resource",
"resource_type": "support-case_v2",
"resource_id": "PR42",
"facet": null,
"interaction": null
}
}
]

View file

@ -18,7 +18,10 @@ target = resource-target / opaque-target
resource-target = resource-type "=" resource-id
opaque-target = dot-string
resource-type = resource-type-char *resource-type-char
; 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)
@ -43,7 +46,7 @@ atom-char = ALPHA / DIGIT /
"#" / "%" / "&" / "'" / "*" / "-" / "/" / "?" /
"_" / "`" / "{" / "|" / "}" / "~"
resource-type-char = ALPHA / DIGIT / "-" / "_"
LCALPHA = %x61-7A ; a-z
resource-id-char = atom-char / "="
value-char = atom-char / "="

View file

@ -3,10 +3,11 @@
import argparse
import json
import re
from pathlib import Path
ATOM = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789#%&'*-/ ?_`{|}~".replace(" ", ""))
RTYPE = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
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):
@ -78,7 +79,7 @@ def parse(local_part, profile="p1"):
raise ValueError("empty resource type")
if not rid:
raise ValueError("empty resource id")
if not all(c in RTYPE for c in rtype):
if not RTYPE.fullmatch(rtype):
raise ValueError("invalid resource type")
if not _valid_dot_string(rid, ATOM | set("=")):
raise ValueError("invalid resource id")

View file

@ -46,7 +46,7 @@
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[a-z0-9][a-z0-9_-]*$"
"pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$"
},
"additionalProperties": {
"type": "object",

View file

@ -311,5 +311,43 @@
"profile": "p1",
"valid": false,
"error": "non-ASCII outside canonical MAS v0.1"
},
{
"id": "p1-042",
"local_part": "Ticket=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): resource types are lower-case"
},
{
"id": "p1-043",
"local_part": "my--type=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): no doubled separator in resource types"
},
{
"id": "p1-044",
"local_part": "type-=PR42",
"profile": "p1",
"valid": false,
"error": "invalid resource type",
"note": "v0.1.1 (SCP-2): no trailing separator"
},
{
"id": "p1-045",
"local_part": "support-case_v2=PR42",
"profile": "p1",
"valid": true,
"parsed": {
"target": "support-case_v2=PR42",
"target_kind": "resource",
"resource_type": "support-case_v2",
"resource_id": "PR42",
"facet": null,
"interaction": null
}
}
]

View file

@ -256,15 +256,8 @@
"id": "prop-case-001",
"local_part": "Ticket=PR42",
"profile": "p1",
"valid": true,
"parsed": {
"target": "Ticket=PR42",
"target_kind": "resource",
"facet": null,
"interaction": null,
"resource_type": "Ticket",
"resource_id": "PR42"
},
"note": "grammar permits upper-case resource types; statutes register lower-case"
"valid": false,
"error": "invalid-resource-type",
"note": "v0.1.1 (SCP-2): resource types are lower-case; was valid in v0.1"
}
]

View file

@ -11,6 +11,8 @@ class (P1 or P2) without re-parsing.
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
@ -28,11 +30,16 @@ LOCAL_PART_MAX_OCTETS = 64
_ALNUM = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
ATOM_CHARS = _ALNUM | frozenset("#%&'*-/?_`{|}~")
RESOURCE_TYPE_CHARS = _ALNUM | frozenset("-_")
# v0.1.1 (SCP-2): lower-case words joined by single "-" or "_".
_RESOURCE_TYPE = re.compile(r"[a-z0-9]+(?:[_-][a-z0-9]+)*")
VALUE_CHARS = ATOM_CHARS | frozenset("=") # resource-id-char and value-char
RESERVED_OPERATORS = frozenset("$^")
def is_resource_type(name: str) -> bool:
return _RESOURCE_TYPE.fullmatch(name) is not None
class MasSyntaxError(ValueError):
"""A local-part is not canonical MAS v0.1 syntax.
@ -102,7 +109,7 @@ def parse_local_part(value: str) -> ParsedLocalPart:
raise MasSyntaxError("empty-resource-type", "resource type is empty")
if not rid:
raise MasSyntaxError("empty-resource-id", "resource id is empty")
if not all(c in RESOURCE_TYPE_CHARS for c in rtype):
if not is_resource_type(rtype):
raise MasSyntaxError("invalid-resource-type", f"invalid resource type {rtype!r}")
if not _dot_string(rid, VALUE_CHARS):
raise MasSyntaxError("invalid-resource-id", f"invalid resource id {rid!r}")

View file

@ -46,7 +46,7 @@
"type": "object",
"default": {},
"propertyNames": {
"pattern": "^[a-z0-9][a-z0-9_-]*$"
"pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$"
},
"additionalProperties": {
"type": "object",

View file

@ -15,7 +15,7 @@ from typing import Callable, Mapping, Optional
from jsonschema import Draft202012Validator, FormatChecker
from .parser import ATOM_CHARS, RESOURCE_TYPE_CHARS, MasSyntaxError, parse_local_part
from .parser import ATOM_CHARS, MasSyntaxError, is_resource_type, parse_local_part
ERROR, WARNING = "error", "warning"
SCHEMA, SEMANTIC = "schema", "semantic"
@ -140,7 +140,7 @@ def semantic_findings(doc: Mapping, host: Optional[str] = None,
for name, spec in doc.get("resource_types", {}).items():
path = f"/resource_types/{name}"
if not name or not all(c in RESOURCE_TYPE_CHARS for c in name):
if not is_resource_type(name):
add(ERROR, "invalid-resource-type-name", path, "name violates MAS resource-type syntax")
if spec.get("identifier_policy") == "sequential-allowed":
add(WARNING, "enumerable-identifiers", path,

View file

@ -70,7 +70,7 @@ SCP-6 split rules), and record which proposals are accepted.
```task
id: MAS-WP-0005-T02
status: todo
status: progress
priority: high
state_hub_task_id: "eb4c83d0-5b2c-5b7c-a6e3-57b85fe78334"
```
@ -80,6 +80,24 @@ types), SCP-3 (type intake, `intake_for`), SCP-5 (intake auto-reply
bounds), and the SCP-7 recommendation (case-robust identifiers). Add
fixtures and update the parser, policy and schema.
Decisions (founder, 2026-10-18): keep the folder `mas-v0.1-spec/` and mark
the revision v0.1.1 inside it (SPECIFICATION §1, README, new CHANGELOG).
For SCP-5, write the spec text now and track the DMARC-gated reply code as
its own task.
- 2026-10-18 SCP-2 done:
- grammar `resource-type = type-word *(("-" / "_") type-word)`;
- statute schema key pattern `^[a-z0-9]+([_-][a-z0-9]+)*$` (bundle and
vendored copy);
- `mas_core.parser.is_resource_type`, also used by statute lint;
- the bundle's reference parser;
- §6.1 text and the CHANGELOG;
- fixtures p1-042 to p1-045, and prop-case-001 flipped to invalid.
141 tests pass, and the reference parser agrees with all 35 bundle
fixtures. SCP-2 text corrected: `Ticket=PR42` becomes non-canonical,
not opaque.
## Reference service: case-robust ticket ids
```task