diff --git a/examples/statute/great-service.example/.well-known/mas b/examples/statute/great-service.example/.well-known/mas index c48ad1a..3e10790 100644 --- a/examples/statute/great-service.example/.well-known/mas +++ b/examples/statute/great-service.example/.well-known/mas @@ -25,6 +25,7 @@ "addresses": { "ticket": { "kind": "function", + "intake_for": "ticket", "description": "Ticket intake; '!MODE' requests a continuation mode" }, "support": { diff --git a/examples/statute/mas.coulomb.social/.well-known/mas b/examples/statute/mas.coulomb.social/.well-known/mas index 33bbc2c..04c3f5a 100644 --- a/examples/statute/mas.coulomb.social/.well-known/mas +++ b/examples/statute/mas.coulomb.social/.well-known/mas @@ -25,6 +25,7 @@ "addresses": { "ticket": { "kind": "function", + "intake_for": "ticket", "description": "Ticket intake; '!MODE' requests a continuation mode" }, "support": { diff --git a/examples/ticket_service/ticket_service.py b/examples/ticket_service/ticket_service.py index 0489b31..5810b3a 100644 --- a/examples/ticket_service/ticket_service.py +++ b/examples/ticket_service/ticket_service.py @@ -5,6 +5,7 @@ Consumes normalized MAS events from `mas-gateway` (POST /mas-events) and proves the canonical lifecycle: A create ticket!callback@DOMAIN -> new ticket, reply from ticket=ID@DOMAIN + (any address the statute declares with "intake_for": "ticket") B correlate ticket=ID[+facet]@DOMAIN -> message attached to ID; subject ignored C closed ticket=ID@DOMAIN (closed) -> archived, ticket not mutated, one notice D unknown ticket=NOPE@DOMAIN -> quarantined, no reply (no backscatter) @@ -83,7 +84,7 @@ class TicketService: "discovery": mas.get("discovery")} if mas.get("kind") == "resource" and mas.get("resource_type") == RESOURCE_TYPE: return self._to_resource(event, mas, msg) - if mas.get("target_syntax") == "opaque" and mas.get("target") == INTAKE_TARGET: + if mas.get("intake_for") == RESOURCE_TYPE: # declared by the statute (SCP-3) return self._create(event, mas, msg) return 404, {"status": "not-handled", "action": "ignored"} diff --git a/mas-v0.1-spec/CHANGELOG.md b/mas-v0.1-spec/CHANGELOG.md index 02ebe88..f9d7692 100644 --- a/mas-v0.1-spec/CHANGELOG.md +++ b/mas-v0.1-spec/CHANGELOG.md @@ -18,6 +18,11 @@ Errata from the reference experiment and the live provider test `my--type=X` and `type-=X` are no longer canonical. New fixtures: p1-042 to p1-045. +- **SCP-3, type intake.** Address registry entries may carry + `intake_for: TYPE` (statute schema). This explicitly makes a function + address the intake for a declared resource type (§7). An opaque target + equal to a type name has no intake semantics without it. + ## v0.1 Initial draft. diff --git a/mas-v0.1-spec/SPECIFICATION.md b/mas-v0.1-spec/SPECIFICATION.md index a916999..7fef872 100644 --- a/mas-v0.1-spec/SPECIFICATION.md +++ b/mas-v0.1-spec/SPECIFICATION.md @@ -235,6 +235,19 @@ created or selected by the service. `!callback` is a preference/request, not a command, guarantee, SLA, or proof of acceptance. +The bare target in `ticket!callback` is an opaque target. It has no link +to the resource type `ticket` merely because the names are equal. A +statute declares the link on the address registry entry: + + "addresses": { "ticket": { "kind": "function", "intake_for": "ticket" } } + +An address whose base target is registered with `intake_for: T` is the +*intake* for resource type `T`: mail to it requests creation or selection +of a resource of type `T`. The entry MUST have `kind` `function`, and `T` +MUST be a resource type the statute declares or inherits. Without such a +registration a target has no intake semantics, even if it equals a declared +resource type. + ## 8. Address equivalence MAS does not impose case folding, dot folding, alias folding, or Unicode diff --git a/mas-v0.1-spec/schema/mas-statute-v0.1.schema.json b/mas-v0.1-spec/schema/mas-statute-v0.1.schema.json index 983f883..3632044 100644 --- a/mas-v0.1-spec/schema/mas-statute-v0.1.schema.json +++ b/mas-v0.1-spec/schema/mas-statute-v0.1.schema.json @@ -148,6 +148,11 @@ "description": { "type": "string", "maxLength": 500 + }, + "intake_for": { + "type": "string", + "pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$", + "description": "v0.1.1 (SCP-3): this function is the intake for the named resource type" } } } diff --git a/mas_conformance/statutes/valid-great-service.json b/mas_conformance/statutes/valid-great-service.json index 755f429..30944a2 100644 --- a/mas_conformance/statutes/valid-great-service.json +++ b/mas_conformance/statutes/valid-great-service.json @@ -25,6 +25,10 @@ "addresses": { "support": { "kind": "function" + }, + "ticket": { + "kind": "function", + "intake_for": "ticket" } } } diff --git a/mas_core/policy.py b/mas_core/policy.py index a046b0f..66d9cbe 100644 --- a/mas_core/policy.py +++ b/mas_core/policy.py @@ -33,6 +33,7 @@ class AddressSemantics: kind: str kind_source: str registry_key: Optional[str] = None + intake_for: Optional[str] = None resource_type: Optional[str] = None resource_id: Optional[str] = None facet: Optional[str] = None @@ -52,6 +53,7 @@ class AddressSemantics: "kind": self.kind, "kind_source": self.kind_source, "registry_key": self.registry_key, + "intake_for": self.intake_for, "resource_type": self.resource_type, "resource_id": self.resource_id, "facet": self.facet, @@ -107,7 +109,7 @@ def resolve(parsed, statute: Optional[Statute] = None, case = statute.local_part_case if statute else "exact" dots = statute.dot_folding if statute else False - kind, source, reg_key = "opaque", SOURCE_NONE, None + kind, source, reg_key, intake_for = "opaque", SOURCE_NONE, None, None rtype = rid = None target = parsed.target @@ -133,11 +135,12 @@ def resolve(parsed, statute: Optional[Statute] = None, if statute is not None: reg_key, entry = _lookup(statute.addresses, target.value, case, dots) if entry: - kind, source = entry["kind"], SOURCE_STATUTE + kind, source, intake_for = entry["kind"], SOURCE_STATUTE, entry.get("intake_for") if source == SOURCE_NONE: k, entry = _lookup(local.get("addresses", {}), target.value, case, dots) if entry: reg_key, kind, source = k, entry["kind"], SOURCE_LOCAL + intake_for = entry.get("intake_for") if source == SOURCE_NONE: unknowns.append("kind: no registry entry; punctuation is not a classifier") @@ -158,7 +161,7 @@ def resolve(parsed, statute: Optional[Statute] = None, return AddressSemantics( address=address, parsed=parsed, namespace_confirmed=statute is not None, - kind=kind, kind_source=source, registry_key=reg_key, + 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), diff --git a/mas_core/schema/mas-statute-v0.1.schema.json b/mas_core/schema/mas-statute-v0.1.schema.json index 983f883..3632044 100644 --- a/mas_core/schema/mas-statute-v0.1.schema.json +++ b/mas_core/schema/mas-statute-v0.1.schema.json @@ -148,6 +148,11 @@ "description": { "type": "string", "maxLength": 500 + }, + "intake_for": { + "type": "string", + "pattern": "^[a-z0-9]+([_-][a-z0-9]+)*$", + "description": "v0.1.1 (SCP-3): this function is the intake for the named resource type" } } } diff --git a/mas_core/statute.py b/mas_core/statute.py index f8ff380..1665775 100644 --- a/mas_core/statute.py +++ b/mas_core/statute.py @@ -165,6 +165,14 @@ def semantic_findings(doc: Mapping, host: Optional[str] = None, 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, diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 9c7033b..194470a 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -107,6 +107,13 @@ def test_send_cap_keeps_replies_in_outbox(env): assert [r["sent"] for r in svc.outbox()] == [1, 1, 0] # third recorded, not sent +def test_only_declared_intake_creates(env): + # SCP-3: "support" is a function without intake_for, so it creates nothing + svc, send, *_ = env + d = send(f"support@{D}", "hello") + assert d.response["status"] == "not-handled" and svc.outbox() == [] + + def test_no_auto_reply_to_automated_mail(env): svc, send, *_ = env send(f"ticket@{D}", "out of office", extra="Auto-Submitted: auto-replied\r\n") diff --git a/tests/test_policy.py b/tests/test_policy.py index 542666a..5b537a4 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -95,3 +95,11 @@ def test_local_registry_fallback_and_precedence(): def test_deterministic(): p = parse_local_part("ticket=PR42+comments") assert resolve(p, EX) == resolve(p, EX) + + +def test_intake_for_is_reported(): + # SCP-3: the statute declares which function is a type's intake + sem = resolve(parse_address("ticket!callback@great-service.example"), GS) + 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 diff --git a/tests/test_statute.py b/tests/test_statute.py index e6b0381..f59bddc 100644 --- a/tests/test_statute.py +++ b/tests/test_statute.py @@ -75,3 +75,15 @@ def test_inheritance_cycles(length): with pytest.raises(InheritanceCycle) as exc: resolve_inheritance(docs[url(names[0])], url(names[0]), docs.__getitem__) assert exc.value.chain[0] == exc.value.chain[-1] + + +def _with_address(entry): + doc = statute_document("valid-great-service.json") + doc["addresses"]["intake"] = entry + return {f.code: f.severity for f in lint(doc, "great-service.example")} + + +def test_intake_for_lint(): + assert "intake-unknown-type" not in _with_address({"kind": "function", "intake_for": "ticket"}) + assert _with_address({"kind": "function", "intake_for": "invoice"})["intake-unknown-type"] == ERROR + assert _with_address({"kind": "group", "intake_for": "ticket"})["intake-not-function"] == ERROR 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 2159ece..ba3fed1 100644 --- a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md +++ b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md @@ -102,6 +102,21 @@ its own task. strict); the gateway now parses with them; fixture p1-040 note and CHANGELOG. The end-to-end test now uses a real non-MAS recipient, because `"quoted"@` is equivalent to `quoted@`. 148 tests pass. +- 2026-10-18 SCP-3 done: + - §7 text and the CHANGELOG; + - statute schema `addresses.*.intake_for`, with the resource-type + pattern (bundle and vendored copy); + - lint `intake-not-function` (error) and `intake-unknown-type` (error, + or warning when the statute inherits); + - the policy reports `intake_for`, and it reaches gateway events; + - the ticket service creates tickets only for + `intake_for == "ticket"`, not for a hard-coded target name; + - example statutes (great-service, mas.coulomb.social) and the + conformance statute declare the intake. + + 151 tests pass. The live statute needs a redeploy to carry + `intake_for`; until then the new ticket service would not recognise + the intake. ## Reference service: case-robust ticket ids