diff --git a/mas_core/policy.py b/mas_core/policy.py index 66d9cbe..3f87a77 100644 --- a/mas_core/policy.py +++ b/mas_core/policy.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Mapping, Optional -from .model import ParsedAddress, ParsedLocalPart, ResourceTarget +from .model import Facet, Interaction, OpaqueTarget, ParsedAddress, ParsedLocalPart, ResourceTarget, _render from .statute import Statute # Kind sources, in the order they are consulted. @@ -41,6 +41,7 @@ class AddressSemantics: equivalence_key: str = "" failures: tuple[SemanticFailure, ...] = () unknowns: tuple[str, ...] = () + stand_ins: tuple[str, ...] = () # v0.2: "resource" and/or "interaction" @property def ok(self) -> bool: @@ -60,6 +61,8 @@ class AddressSemantics: "interaction": self.interaction, "required_profile": self.parsed.required_profile.value, "equivalence_key": self.equivalence_key, + "canonical_local_part": str(self.parsed.raw), + "stand_ins": list(self.stand_ins), "failures": [{"code": f.code, "message": f.message} for f in self.failures], "unknowns": list(self.unknowns), } @@ -82,10 +85,56 @@ def equivalence_key(parsed: ParsedLocalPart, statute: Optional[Statute]) -> str: if statute is None: return base if isinstance(parsed.target, ResourceTarget): - return _fold(base, statute.local_part_case, False) + key = _fold(base, statute.local_part_case, False) + if statute.resource_id_case == "insensitive": + key = key.lower() # resource types are lower-case already (v0.1.1) + return key return _fold(base, statute.local_part_case, statute.dot_folding) +def _split_interaction(value: str, statute: Statute) -> tuple[str, Optional[str]]: + """`x---MODE` -> (`x`, MODE) when MODE is enabled; split at the last `---`.""" + i = value.rfind("---") + if i > 0: + mode = value[i + 3:] + if mode and not mode.startswith("-") and statute.interaction_mode_enabled(mode): + return value[:i], mode + return value, None + + +def apply_stand_ins(parsed: ParsedLocalPart, statute: Optional[Statute]) -> tuple[ParsedLocalPart, tuple[str, ...]]: + """Read v0.2 stand-ins as canonical syntax (spec v0.2 section 6.3). + + Only a statute that enables a stand-in gives it meaning. Interaction + (`---MODE`, enabled mode) is matched first, then resource (`TYPE--ID`, + declared type, first `--`). Returns the canonical parse and the stand-ins used. + """ + if statute is None or not (statute.alt_resource or statute.alt_interaction): + return parsed, () + target, facet, interaction = parsed.target, parsed.facet, parsed.interaction + used: list[str] = [] + if statute.alt_interaction and statute.p2 and interaction is None: + if facet is not None: + rest, mode = _split_interaction(facet.value, statute) + if mode and rest: + facet, interaction = Facet(rest), Interaction(mode) + elif isinstance(target, OpaqueTarget): + rest, mode = _split_interaction(target.value, statute) + if mode and rest: + target, interaction = OpaqueTarget(rest), Interaction(mode) + if interaction is not None: + used.append("interaction") + if statute.alt_resource and isinstance(target, OpaqueTarget) and "--" in target.value: + rtype, rid = target.value.split("--", 1) + if rtype in statute.resource_types and rid and not rid.startswith("-"): + target = ResourceTarget(rtype, rid) + used.append("resource") + if not used: + return parsed, () + return ParsedLocalPart(raw=_render(target, facet, interaction), target=target, + facet=facet, interaction=interaction), tuple(used) + + def _lookup(registry: Mapping[str, Mapping], key: str, case: str, dots: bool): if key in registry: return key, registry[key] @@ -103,6 +152,7 @@ def resolve(parsed, statute: Optional[Statute] = None, if isinstance(parsed, ParsedAddress): domain, parsed = parsed.domain, parsed.local address = f"{parsed.raw}@{domain}" if domain else parsed.raw + parsed, stand_ins = apply_stand_ins(parsed, statute) local = local_registry or {} failures: list[SemanticFailure] = [] unknowns: list[str] = [] @@ -164,5 +214,5 @@ def resolve(parsed, statute: Optional[Statute] = None, kind=kind, kind_source=source, registry_key=reg_key, intake_for=intake_for, resource_type=rtype, resource_id=rid, facet=facet, interaction=mode, equivalence_key=equivalence_key(parsed, statute), - failures=tuple(failures), unknowns=tuple(unknowns), + failures=tuple(failures), unknowns=tuple(unknowns), stand_ins=stand_ins, ) diff --git a/mas_core/statute.py b/mas_core/statute.py index b7b0fe2..11bec5c 100644 --- a/mas_core/statute.py +++ b/mas_core/statute.py @@ -195,6 +195,26 @@ def semantic_findings(doc: Mapping, host: Optional[str] = None, 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("/"): diff --git a/mas_gateway/core.py b/mas_gateway/core.py index 1205fce..daab484 100644 --- a/mas_gateway/core.py +++ b/mas_gateway/core.py @@ -92,9 +92,9 @@ def build_event(recipient: str, message: dict, resolver: Resolver) -> dict: "valid": True, "domain": addr.domain, "local_part": addr.local.raw, - "base": addr.local.base, - "target": str(addr.local.target), - "target_syntax": addr.local.target.kind, + "base": sem.parsed.base, + "target": str(sem.parsed.target), + "target_syntax": sem.parsed.target.kind + ("-alternate" if "resource" in sem.stand_ins else ""), "discovery": {"ok": True, "url": disc.url} if disc.ok else {"ok": False, "failure": disc.kind.value, "detail": disc.detail}, **{k: v for k, v in sem.to_dict().items() if k != "address"}, diff --git a/tests/test_policy.py b/tests/test_policy.py index 5b537a4..e2046e5 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -103,3 +103,57 @@ def test_intake_for_is_reported(): assert sem.kind == "function" and sem.intake_for == "ticket" and sem.interaction == "callback" assert resolve(parse_address("support@great-service.example"), GS).intake_for is None assert resolve(parse_address("ticket@great-service.example"), None).intake_for is None + + +# -- v0.2 stand-ins (spec v0.2 section 6.3) ---------------------------------------- + +def _v02_statute(resource=True, interaction=True, id_case="insensitive"): + doc = statute_document("valid-great-service.json") + doc["mas_version"] = "0.2" + doc["alternate_syntax"] = {"resource": resource, "interaction": interaction} + doc["equivalence"]["resource_id_case"] = id_case + return build_statute(doc, "great-service.example") + + +V2 = _v02_statute() + + +@pytest.mark.parametrize("raw, canonical, stand_ins", [ + ("ticket---callback", "ticket!callback", ("interaction",)), + ("ticket--gs1---callback", "ticket=gs1!callback", ("interaction", "resource")), + ("ticket--a---b", "ticket=a---b", ("resource",)), # b is not an enabled mode + ("ticket--gs1+comments", "ticket=gs1+comments", ("resource",)), + ("ticket--gs-85qj8wc09p+comments", "ticket=gs-85qj8wc09p+comments", ("resource",)), + ("support---callback", "support!callback", ("interaction",)), + ("invoice--x1", "invoice--x1", ()), # undeclared type + ("ticket=gs1!callback", "ticket=gs1!callback", ()), # already canonical +]) +def test_stand_in_table(raw, canonical, stand_ins): + sem = resolve(parse_address(f"{raw}@great-service.example"), V2) + assert sem.to_dict()["canonical_local_part"] == canonical and sem.stand_ins == stand_ins + + +def test_stand_in_resource_correlates_like_canonical(): + alt = resolve(parse_address("ticket--GS-85QJ8WC09P+comments@great-service.example"), V2) + canon = resolve(parse_address("ticket=gs-85qj8wc09p@great-service.example"), V2) + assert alt.kind == "resource" and alt.resource_id == "GS-85QJ8WC09P" and alt.facet == "comments" + assert alt.equivalence_key == canon.equivalence_key == "ticket=gs-85qj8wc09p" + + +def test_stand_in_intake_reaches_registry(): + sem = resolve(parse_address("ticket---callback@great-service.example"), V2) + assert sem.kind == "function" and sem.intake_for == "ticket" and sem.interaction == "callback" + + +def test_stand_ins_need_the_statute_switch(): + off = _v02_statute(resource=False, interaction=False, id_case="exact") + for raw in ("ticket--gs1", "ticket---callback"): + sem = resolve(parse_address(f"{raw}@great-service.example"), off) + assert sem.stand_ins == () and sem.kind == "opaque" + assert resolve(parse_address("ticket--gs1@great-service.example"), GS).stand_ins == () # v0.1 + assert resolve(parse_address("ticket--gs1@great-service.example"), None).stand_ins == () + + +def test_resource_id_case_exact_keeps_case(): + exact = _v02_statute(id_case="exact") + assert resolve(parse_address("ticket=GS-1@great-service.example"), exact).equivalence_key == "ticket=GS-1" diff --git a/tests/test_statute.py b/tests/test_statute.py index 60817cd..a5ffd04 100644 --- a/tests/test_statute.py +++ b/tests/test_statute.py @@ -110,3 +110,14 @@ 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")) + + +def test_stand_in_lint(): + doc = _v02(alternate_syntax={"resource": True, "interaction": True}) + doc["addresses"]["ticket--legacy"] = {"kind": "function"} + doc["addresses"]["sales---callback"] = {"kind": "function"} + codes = [f.code for f in lint(doc, "great-service.example") if f.severity == ERROR] + assert codes.count("stand-in-collision") == 2 + off = _v02() + off["addresses"]["ticket--legacy"] = {"kind": "function"} + assert "stand-in-collision" not in {f.code for f in lint(off, "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 5c0081d..73dc690 100644 --- a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md +++ b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md @@ -256,7 +256,7 @@ against its own schema. Discovery accepts both. ```task id: MAS-WP-0005-T08 -status: todo +status: done priority: medium state_hub_task_id: "a345e073-04ca-54e1-bac7-837fe7955e4e" ``` @@ -266,6 +266,29 @@ The policy interprets `---MODE` (interaction first, enabled mode only) and `resource_id_case: insensitive` in the equivalence key. Statute lint covers the stand-in constraints. Statute-aware fixtures. +- 2026-10-18 done: + - `mas_core.policy.apply_stand_ins` runs first in `resolve()`. It + matches the interaction (`---MODE`, enabled mode, last `---`; in the + facet or the target) and then the resource (`TYPE--ID`, declared type, + first `--`). Everything later (classification, registry, + `intake_for`, equivalence) sees the canonical parse, and the parser + stays statute-blind. + - `AddressSemantics` gains `stand_ins` and `canonical_local_part`; the + equivalence key lower-cases resource ids under + `resource_id_case: insensitive`. + - The gateway event reports the canonical target and base, plus + `target_syntax: resource-alternate` when `--` was used, while + `local_part` keeps what arrived. + - Lint: `stand-in-collision` (registry keys that would change meaning), + `stand-in-mode-name`, and `stand-in-without-p2` (warning). + - Tests: the spec §6.3 example table, correlation of + `ticket--GS-…+comments` with `ticket=gs-…`, intake via `---`, the + switch requirement (v0.1, off, no statute), exact id case, and lint. + 168 tests pass. + - A v0.2 statute for mas.coulomb.social lints clean, and the gateway + turns `ticket--gs-85qj8wc09p+comments@` into resource + `ticket=gs-85qj8wc09p` with facet `comments`. + ## v0.2 sending side: destination transforms in the generator ```task