MAS-WP-0013 finished: mode contract flows runnable from the bundle alone

conformance/FLOWS.md defines setup, steps, captures and expectations, all
observable at the contract boundary. flows.json carries setups (peer ids),
fixed test keys, statute, scenario and explicit assign steps;
scenarios.json holds the callback scenarios (the stub loads them).
CONTRACT.md defines the case.assign response. Runner reads only the
bundle and rejects undefined keys; new flow s-006 (held over cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
tegwick 2026-10-19 12:49:15 +02:00
parent 8de8fddf37
commit ecb1ba47f6
14 changed files with 1118 additions and 269 deletions

View file

@ -16,16 +16,13 @@ import uuid
from collections import Counter
from dataclasses import dataclass, field
from importlib import resources
from typing import Optional
from . import _load, flow_fixtures, message_errors, message_fixtures, transitions
from . import _load, message_errors, message_fixtures, transitions
from .contract import allowed
from .harness import Harness
from .signing import AuthError, sign, signature, verify
MASTER = {"role": "master", "fields": ("phone", "time", "scenario"), "timeout": 3600}
SLAVE = {"role": "slave"}
@dataclass
class Report:
passed: int = 0
@ -107,65 +104,141 @@ def _inject(h, spec, ctx):
return e.code, json.loads(e.read().decode() or "null")
STEPS = {"mail", "mode", "advance", "master", "inject"}
EXPECT = {"event_accepted", "mode_statuses", "mode_received", "mode_received_last",
"master_received", "relayed", "mails_sent", "last_mail_contains", "response_ok",
"response_reason", "response_held", "case_address", "http_status", "error"}
def undefined_keys(flow: dict) -> list[str]:
"""Steps or expectations not defined in conformance/FLOWS.md."""
bad = []
for n, step in enumerate(flow["steps"], 1):
bad += [f"step {n}: {k}" for k in set(step) - STEPS - {"expect"}]
bad += [f"step {n}: expect {k}" for k in set(step.get("expect", {})) - EXPECT]
return bad
def _fill(value, ctx):
if isinstance(value, str):
for k, v in ctx.items():
value = value.replace("{" + k + "}", str(v))
if isinstance(v, str):
value = value.replace("{" + k + "}", v)
return value
if isinstance(value, dict):
return {k: _fill(v, ctx) for k, v in value.items()}
return value
def _statute_path():
return resources.files(__package__).joinpath("conformance/statute.json")
def run_flow(flow: dict) -> list[str]:
"""Returns the problems found; empty when the flow passes."""
with resources.as_file(_statute_path()) as statute:
if flow["role"] == "master":
roles = {"ticket": MASTER}
def _roles(setup: dict) -> dict:
"""A flow setup (peer ids only) as harness role configs (with stub URLs)."""
def url_for(v):
if isinstance(v, str) and v.startswith("<mode:"):
return "{callback}"
if isinstance(v, dict):
return {k: url_for(x) for k, x in v.items()}
return v
roles = {}
for rtype, cfg in setup["roles"].items():
cfg = url_for(dict(cfg))
if cfg["role"] == "master":
cfg["modes"] = {m: {"url": "{callback}", **e} for m, e in cfg.get("modes", {}).items()}
cfg["fields"] = tuple(cfg.get("fields", ("phone", "time")))
else:
roles = {"case": {**SLAVE, **flow.get("config", {})}}
h = Harness(statute, roles, scenario=flow.get("scenario", "completed"), auto_assign=True)
ctx, problems = {}, []
last_command = None
timed_out: list = []
cfg["master_url"] = "{master}"
roles[rtype] = cfg
return roles
def _capture(ctx, items):
for m in items:
if not isinstance(m, dict):
continue
if isinstance(m.get("case"), dict):
ctx["case"] = m["case"]
ctx["case_id"] = m["case"]["address"].split("=", 1)[-1].rsplit("@", 1)[0]
if m.get("type") in ("case.message", None) and m.get("message_ref"):
ctx["message_ref"] = m["message_ref"]
if m.get("type") == "case.intake":
ctx["intake_ref"] = m["intake_ref"]
if m.get("type") in ("handoff.request", "handoff.requested"):
ctx["handoff_id"] = m["handoff_id"]
def _inject(h, spec, ctx):
"""Send a crafted request straight to an email endpoint (FLOWS.md, inject)."""
if spec["endpoint"] == "status":
reqs = [m for m in h.callback.received if m["type"] == "handoff.request"]
url = reqs[-1]["status_url"]
if spec.get("token") == "bogus":
url = url.rsplit("/", 1)[0] + "/not-a-token"
else:
url = h.email.base_url + {"master": "/master", "events": "/mas-events"}[spec["endpoint"]]
msg = {"mas_modes_version": "0.1", "id": uuid.uuid4().hex, **_fill(spec["message"], ctx)}
if msg.get("case") == "current":
msg["case"] = ctx["case"]
body = json.dumps(msg).encode()
headers = {"Content-Type": "application/json"}
mode = spec.get("sign", "valid")
if mode != "none":
peer = spec["as"]
key = h.keys.get(peer, b"k" * 32) if mode != "wrong-key" else b"x" * 32
ts = int(time.time()) - (400 if mode == "stale" else 0)
headers.update(sign(body, peer, key, ts))
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "null")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "null")
def _master(h, cmd, ctx):
c = cmd["command"]
if c == "assign":
return h.master.assign(cmd.get("intake_ref", ctx.get("intake_ref")), cmd["resource_id"])
if c == "send":
return h.master.send(ctx["case"], cmd["in_reply_to"], cmd["body"], cmd.get("subject"))
if c == "cancel":
return h.master.cancel(ctx["case"], cmd["handoff_id"])
if c == "policy":
return h.master.policy(ctx["case"], cmd.get("autonomous_send"), cmd.get("transfers"))
raise KeyError(f"unknown master command {c!r}")
def run_flow(flow: dict, doc: Optional[dict] = None) -> list[str]:
"""Run one flow from the bundle (FLOWS.md). Returns the problems found."""
doc = doc or _load("conformance/flows.json")
bad = undefined_keys(flow)
if bad:
return [f"undefined key(s): {bad}"]
keys = {p: bytes.fromhex(k) for p, k in doc["peers"].items()}
with resources.as_file(resources.files(__package__).joinpath("conformance", doc["statute"])) as st:
h = Harness(st, _roles(doc["setups"][flow["setup"]]), scenario=flow["scenario"],
auto_assign=False, max_sent=flow.get("max_sent"), keys=keys)
ctx: dict = {}
problems = []
try:
for n, step in enumerate(flow["steps"], 1):
resp = None
seen = (len(h.callback.received), len(h.master.received))
event, response = None, None
if "mail" in step:
m = _fill(step["mail"], ctx)
d = h.mail(m["to"], m.get("text", ""), dmarc=m.get("dmarc", "pass"))
resp = d.response if isinstance(d.response, dict) else {}
event = h.mail(m["to"], m.get("text", ""), subject=m.get("subject", "Help needed"),
dmarc=m.get("dmarc", "pass"))
elif "mode" in step:
h.callback.run()
elif "advance" in step:
timed_out = h.advance(step["advance"])
elif "pump" in step:
results = h.master.pump()
resp = results[-1][1] if results else {}
elif "inject" in step:
last_command = _inject(h, step["inject"], ctx)
h.advance(step["advance"])
elif "master" in step:
cmd = _fill(step["master"], {**ctx, "callback": ""})
case = {"address": ctx["case_address"], "key": ctx["case_key"]}
if cmd["command"] == "send":
last_command = h.master.send(case, cmd["in_reply_to"], cmd["body"])
elif cmd["command"] == "cancel":
last_command = h.master.cancel(case, cmd["handoff_id"])
if isinstance(resp, dict):
if resp.get("ticket_id"):
ctx["case_id"] = resp["ticket_id"]
if isinstance(resp.get("case"), dict):
ctx["case_id"] = resp["case"]["address"].split("=", 1)[1].split("@")[0]
ctx["case_address"], ctx["case_key"] = resp["case"]["address"], resp["case"]["key"]
for k in ("message_ref", "handoff_id", "intake_ref"):
if resp.get(k):
ctx[k] = resp[k]
response = _master(h, _fill(step["master"], ctx), ctx)
elif "inject" in step:
response = _inject(h, step["inject"], ctx)
_capture(ctx, h.callback.received[seen[0]:] + h.master.received[seen[1]:])
if response is not None:
_capture(ctx, [response[1]])
for key, want in step.get("expect", {}).items():
got = _observe(h, key, resp, last_command, timed_out)
got = _observe(h, key, event, response)
if key == "master_received":
got = {t: got.get(t, 0) for t in want}
elif key == "last_mail_contains":
@ -177,36 +250,37 @@ def run_flow(flow: dict) -> list[str]:
return problems
def _observe(h, key, resp, last_command, timed_out):
if key == "response_status":
return (resp or {}).get("status")
if key == "statuses":
def _observe(h, key, event, response):
body = (response[1] if response else None) or {}
if key == "event_accepted":
return event is not None and event.status is not None and 200 <= event.status < 300
if key == "mode_statuses":
return [m["status"] for hd in h.callback.handoffs.values() for m in hd.sent]
if key == "relayed":
return [m["status"] for m in h.master.of_type("handoff.status")]
if key == "outbox_count":
return len(h.outbox())
if key == "last_mail_contains":
mails = h.outbox()
return mails[-1]["body"] if mails else None
if key == "master_received":
return dict(Counter(m["type"] for m in h.master.received))
if key == "mode_received":
return len(h.callback.received)
if key == "mode_received_last":
return h.callback.received[-1]["type"] if h.callback.received else None
if key == "timed_out":
return len(timed_out)
if key == "master_received":
return dict(Counter(m["type"] for m in h.master.received))
if key == "relayed":
return [m["status"] for m in h.master.of_type("handoff.status")]
if key == "mails_sent":
return len(h.outbox())
if key == "last_mail_contains":
mails = h.outbox()
return mails[-1]["body"] if mails else None
if key == "response_ok":
return body.get("ok")
if key == "response_reason":
return body.get("reason")
if key == "response_held":
return body.get("held")
if key == "case_address":
return (resp or {}).get("case", {}).get("address")
if key == "command_ok":
return (last_command[1] or {}).get("ok")
if key == "command_reason":
return (last_command[1] or {}).get("reason")
return (body.get("case") or {}).get("address")
if key == "http_status":
return last_command[0]
return response[0] if response else None
if key == "error":
return (last_command[1] or {}).get("error")
return body.get("error")
raise KeyError(f"unknown expectation {key!r}")
@ -214,8 +288,9 @@ def run_mode_corpus() -> Report:
report = Report()
check_messages(report)
check_auth(report)
for flow in flow_fixtures():
problems = run_flow(flow)
doc = _load("conformance/flows.json")
for flow in doc["flows"]:
problems = run_flow(flow, doc)
if problems:
report.failures.append((flow["id"], "; ".join(problems)))
else: