MAS-WP-0005-T02: SCP-3 intake_for (statute-declared type intake)

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:18:35 +02:00
parent 0a2d811444
commit a2465643ba
14 changed files with 92 additions and 4 deletions

View file

@ -25,6 +25,7 @@
"addresses": {
"ticket": {
"kind": "function",
"intake_for": "ticket",
"description": "Ticket intake; '!MODE' requests a continuation mode"
},
"support": {

View file

@ -25,6 +25,7 @@
"addresses": {
"ticket": {
"kind": "function",
"intake_for": "ticket",
"description": "Ticket intake; '!MODE' requests a continuation mode"
},
"support": {

View file

@ -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"}

View file

@ -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.

View file

@ -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

View file

@ -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"
}
}
}

View file

@ -25,6 +25,10 @@
"addresses": {
"support": {
"kind": "function"
},
"ticket": {
"kind": "function",
"intake_for": "ticket"
}
}
}

View file

@ -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),

View file

@ -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"
}
}
}

View file

@ -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,

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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