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:
parent
6ee9e4dd54
commit
5b57a9941e
14 changed files with 155 additions and 25 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
17
mas-v0.1-spec/CHANGELOG.md
Normal file
17
mas-v0.1-spec/CHANGELOG.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 / "="
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue