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
299 lines
12 KiB
Python
299 lines
12 KiB
Python
"""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
|
|
access lives in `mas_core.discovery` only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field, replace
|
|
from importlib import resources
|
|
from types import MappingProxyType
|
|
from typing import Callable, Mapping, Optional
|
|
|
|
from jsonschema import Draft202012Validator, FormatChecker
|
|
|
|
from .parser import ATOM_CHARS, MasSyntaxError, is_resource_type, parse_local_part
|
|
|
|
ERROR, WARNING = "error", "warning"
|
|
SCHEMA, SEMANTIC = "schema", "semantic"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
layer: str # "schema" | "semantic"
|
|
severity: str # "error" | "warning"
|
|
code: str
|
|
path: str
|
|
message: str
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.layer} {self.severity} [{self.code}] {self.path or '/'}: {self.message}"
|
|
|
|
|
|
class StatuteInvalid(ValueError):
|
|
def __init__(self, findings: list[Finding]):
|
|
self.findings = [f for f in findings if f.severity == ERROR]
|
|
super().__init__("; ".join(str(f) for f in self.findings))
|
|
|
|
@property
|
|
def layer(self) -> str:
|
|
return SCHEMA if any(f.layer == SCHEMA for f in self.findings) else SEMANTIC
|
|
|
|
@property
|
|
def codes(self) -> set[str]:
|
|
return {f.code for f in self.findings}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Statute:
|
|
namespace: str
|
|
p2: bool
|
|
resource_types: Mapping[str, Mapping]
|
|
interaction_modes: Mapping[str, Mapping]
|
|
local_part_case: str
|
|
dot_folding: bool
|
|
addresses: Mapping[str, Mapping]
|
|
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:
|
|
spec = self.resource_types.get(name)
|
|
return bool(spec and spec.get("enabled"))
|
|
|
|
def interaction_mode_enabled(self, name: str) -> bool:
|
|
spec = self.interaction_modes.get(name)
|
|
return bool(spec and spec.get("enabled"))
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"namespace": self.namespace,
|
|
"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()},
|
|
"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),
|
|
}
|
|
|
|
|
|
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:
|
|
"""Normal domain-name canonicalization: case, trailing dot, IDNA A-label."""
|
|
h = host.strip().rstrip(".").lower()
|
|
try:
|
|
return h.encode("idna").decode("ascii")
|
|
except UnicodeError:
|
|
return h
|
|
|
|
|
|
def schema_findings(doc) -> list[Finding]:
|
|
out = []
|
|
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
|
|
|
|
|
|
def _fold(key: str, case: str, dots: bool) -> str:
|
|
if case == "ascii-insensitive":
|
|
key = key.lower()
|
|
if dots:
|
|
key = key.replace(".", "")
|
|
return key
|
|
|
|
|
|
def semantic_findings(doc: Mapping, host: Optional[str] = None,
|
|
statute_url: Optional[str] = None) -> list[Finding]:
|
|
"""Checks beyond the JSON schema. Assumes `doc` is schema-valid."""
|
|
out: list[Finding] = []
|
|
|
|
def add(sev, code, path, msg):
|
|
out.append(Finding(SEMANTIC, sev, code, path, msg))
|
|
|
|
ns = doc["namespace"]
|
|
if canonical_host(ns) != ns:
|
|
add(WARNING, "namespace-not-canonical", "/namespace",
|
|
f"namespace should be in canonical form {canonical_host(ns)!r}")
|
|
if host is not None and canonical_host(ns) != canonical_host(host):
|
|
add(ERROR, "namespace-mismatch", "/namespace",
|
|
f"statute declares {ns!r} but was retrieved for host {host!r}")
|
|
|
|
p2 = doc["profiles"]["p2"]
|
|
modes = doc.get("interaction_modes", {})
|
|
enabled_modes = [m for m, spec in modes.items() if spec["enabled"]]
|
|
if not p2 and enabled_modes:
|
|
add(ERROR, "p2-disabled-modes-enabled", "/interaction_modes",
|
|
f"profiles.p2 is false but interaction modes are enabled: {', '.join(enabled_modes)}")
|
|
if p2 and not enabled_modes and not doc.get("inherits"):
|
|
add(WARNING, "p2-without-modes", "/profiles/p2",
|
|
"P2 is enabled but no interaction mode is enabled")
|
|
|
|
for name, spec in doc.get("resource_types", {}).items():
|
|
path = f"/resource_types/{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,
|
|
"sequential identifiers are enumerable; identifiers are not credentials")
|
|
|
|
case = doc["equivalence"]["local_part_case"]
|
|
dots = doc["equivalence"]["dot_folding"]
|
|
seen: dict[str, str] = {}
|
|
for key in doc.get("addresses", {}):
|
|
path = f"/addresses/{key}"
|
|
bad = sorted(set(key) & set("+!$^"))
|
|
if bad:
|
|
add(ERROR, "registry-key-operator", path,
|
|
f"registry keys name base opaque targets; {' '.join(bad)} not allowed")
|
|
continue
|
|
try:
|
|
parsed = parse_local_part(key)
|
|
except MasSyntaxError as e:
|
|
add(ERROR, "registry-key-syntax", path, str(e))
|
|
continue
|
|
if parsed.target.kind != "opaque":
|
|
add(ERROR, "registry-key-resource", path,
|
|
"registry keys must be opaque targets; resources are declared via resource_types")
|
|
continue
|
|
intake = doc["addresses"][key].get("intake_for")
|
|
if intake is not None:
|
|
if doc["addresses"][key].get("kind") != "function":
|
|
add(ERROR, "intake-not-function", path, "intake_for requires kind 'function'")
|
|
if intake not in doc.get("resource_types", {}):
|
|
# Resource types may be inherited, so only a warning then.
|
|
add(WARNING if doc.get("inherits") else ERROR, "intake-unknown-type", path,
|
|
f"intake_for names resource type {intake!r}, which this statute does not declare")
|
|
folded = _fold(key, case, dots)
|
|
if folded in seen:
|
|
add(ERROR, "registry-key-collision", path,
|
|
f"collides with {seen[folded]!r} under the declared equivalence policy")
|
|
seen[folded] = key
|
|
|
|
# v0.2 stand-ins (spec 6.3): an enabled stand-in must not change the
|
|
# meaning of names the statute already registers.
|
|
alt = doc.get("alternate_syntax", {})
|
|
modes = doc.get("interaction_modes", {})
|
|
if alt.get("interaction"):
|
|
if not doc["profiles"]["p2"]:
|
|
add(WARNING, "stand-in-without-p2", "/alternate_syntax/interaction",
|
|
"the '---' stand-in has no effect while P2 is disabled")
|
|
for mode in modes:
|
|
if mode.startswith("-"):
|
|
add(ERROR, "stand-in-mode-name", f"/interaction_modes/{mode}",
|
|
"mode names must not start with '-' when the '---' stand-in is enabled")
|
|
for key in doc.get("addresses", {}):
|
|
path = f"/addresses/{key}"
|
|
if alt.get("resource") and "--" in key and key.split("--", 1)[0] in doc.get("resource_types", {}):
|
|
add(ERROR, "stand-in-collision", path, f"{key!r} would read as a resource under the '--' stand-in")
|
|
i = key.rfind("---")
|
|
if alt.get("interaction") and i > 0 and modes.get(key[i + 3:], {}).get("enabled"):
|
|
add(ERROR, "stand-in-collision", path, f"{key!r} would read as an interaction under the '---' stand-in")
|
|
|
|
inherits = doc.get("inherits")
|
|
own_url = statute_url or f"https://{canonical_host(ns)}/.well-known/mas"
|
|
if inherits and inherits.rstrip("/") == own_url.rstrip("/"):
|
|
add(ERROR, "inheritance-cycle", "/inherits", "statute inherits from itself")
|
|
|
|
if doc.get("unicode_local_part"):
|
|
add(WARNING, "unicode-local-part", "/unicode_local_part",
|
|
"experimental: Unicode local-parts are not canonical MAS v0.1 syntax")
|
|
return out
|
|
|
|
|
|
def lint(doc, host: Optional[str] = None) -> list[Finding]:
|
|
"""All findings: schema errors, or (if schema-valid) semantic errors and warnings."""
|
|
found = schema_findings(doc)
|
|
if found:
|
|
return found
|
|
return semantic_findings(doc, host)
|
|
|
|
|
|
def build_statute(doc: Mapping, host: Optional[str] = None,
|
|
statute_url: Optional[str] = None) -> Statute:
|
|
"""Validate (schema + semantics) and build a Statute. Raises StatuteInvalid."""
|
|
found = schema_findings(doc) or semantic_findings(doc, host, statute_url)
|
|
if any(f.severity == ERROR for f in found):
|
|
raise StatuteInvalid(found)
|
|
ro = lambda d: MappingProxyType({k: MappingProxyType(dict(v)) for k, v in d.items()})
|
|
return Statute(
|
|
namespace=canonical_host(doc["namespace"]),
|
|
p2=doc["profiles"]["p2"],
|
|
resource_types=ro(doc.get("resource_types", {})),
|
|
interaction_modes=ro(doc.get("interaction_modes", {})),
|
|
local_part_case=doc["equivalence"]["local_part_case"],
|
|
dot_folding=doc["equivalence"]["dot_folding"],
|
|
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)),
|
|
)
|
|
|
|
|
|
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 replace(child, resource_types=MappingProxyType(rt), interaction_modes=MappingProxyType(im),
|
|
inherited_from=(parent_url,) + parent.inherited_from)
|
|
|
|
|
|
class InheritanceCycle(ValueError):
|
|
def __init__(self, chain: list[str]):
|
|
super().__init__(" -> ".join(chain))
|
|
self.chain = chain
|
|
|
|
|
|
def resolve_inheritance(statute: Statute, own_url: str,
|
|
fetch: Callable[[str], Statute], max_depth: int = 8) -> Statute:
|
|
"""Follow explicit `inherits` URIs only, detecting cycles (A -> B -> A, ...).
|
|
|
|
`fetch(url)` must return an independently validated parent Statute, or
|
|
raise. Parents are fetched for their own namespace; their registry is
|
|
discarded by `merge_defaults`.
|
|
"""
|
|
chain = [own_url.rstrip("/")]
|
|
statutes = [statute]
|
|
cur = statute
|
|
while cur.inherits:
|
|
url = cur.inherits.rstrip("/")
|
|
if url in chain:
|
|
raise InheritanceCycle(chain + [url])
|
|
if len(chain) > max_depth:
|
|
raise InheritanceCycle(chain + [url + " (depth limit)"])
|
|
chain.append(url)
|
|
cur = fetch(cur.inherits)
|
|
statutes.append(cur)
|
|
merged = statutes[-1]
|
|
for i in range(len(statutes) - 2, -1, -1):
|
|
merged = merge_defaults(statutes[i], merged, chain[i + 1])
|
|
return merged
|