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:
parent
0a2d811444
commit
a2465643ba
14 changed files with 92 additions and 4 deletions
|
|
@ -25,6 +25,7 @@
|
|||
"addresses": {
|
||||
"ticket": {
|
||||
"kind": "function",
|
||||
"intake_for": "ticket",
|
||||
"description": "Ticket intake; '!MODE' requests a continuation mode"
|
||||
},
|
||||
"support": {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"addresses": {
|
||||
"ticket": {
|
||||
"kind": "function",
|
||||
"intake_for": "ticket",
|
||||
"description": "Ticket intake; '!MODE' requests a continuation mode"
|
||||
},
|
||||
"support": {
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
"addresses": {
|
||||
"support": {
|
||||
"kind": "function"
|
||||
},
|
||||
"ticket": {
|
||||
"kind": "function",
|
||||
"intake_for": "ticket"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue