mail-address-semantics/mas_modes/stubs/callback.py
tegwick bdf200a600 MAS-WP-0011 finished: peer authentication in mode contract v0.1
CONTRACT.md section 7 (founder-reviewed): pairwise HMAC-SHA256 over
TIMESTAMP.BODY in MAS-Modes-* headers, 300 s window; master commands only
from the configured master; statuses only at the handoff's tokenized URL
from its peer; primed webhooks must name a known peer. mas_modes.signing,
signed contract.post, authenticating json_server, EmailMode/stubs/gateway
signing. conformance/auth.json vectors + 7 authorization flows.

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
2026-10-19 11:25:24 +02:00

161 lines
6.9 KiB
Python

"""Callback mode stub (CONTRACT.md; INTENT.md, Callback stub).
Speaks the mode contract and simulates how a callback can develop. It never
calls anyone. Scenarios are scripted and deterministic: nothing happens
until `step()` (or POST /step) emits the next status. A scenario is chosen
by the request's `details.scenario`, or else by the stub's default.
stub = CallbackStub({"email": key}, default="no-answer-retry")
base = serve(stub.server()) # POST /handoffs (signed), /step (loopback)
Requests are accepted only when signed by email's peer id, and statuses
are signed with the key shared with email (CONTRACT.md section 7).
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from typing import Optional
from .. import message_errors
from ..contract import TERMINAL, Idempotent, json_server, message, post, refuse
AT = "2026-10-20T10:00:00Z"
NEEDED = ("phone", "time")
WAIT = "wait-for-details" # negotiation: block until handoff.update supplies NEEDED
# Each step is (status, extra fields), or WAIT.
SCENARIOS: dict[str, list] = {
"completed": [("scheduled", {"at": AT}), ("running", {}), ("completed", {})],
"negotiate": [WAIT, ("scheduled", {"at": AT}), ("running", {}), ("completed", {})],
"no-answer-retry": [("scheduled", {"at": AT}), ("attempted", {"outcome": "no-answer"}),
("scheduled", {"at": "2026-10-20T14:00:00Z"}), ("running", {}),
("completed", {})],
"no-answer-fail": [("scheduled", {"at": AT}), ("attempted", {"outcome": "no-answer"}),
("scheduled", {"at": "2026-10-20T14:00:00Z"}),
("attempted", {"outcome": "no-answer"}),
("failed", {"detail": "two attempts, no answer"})],
"call-failed": [("scheduled", {"at": AT}), ("running", {}),
("failed", {"detail": "line dropped"})],
"expired": [("scheduled", {"at": AT}), ("expired", {"detail": "slot passed"})],
"unavailable": [("refused", {"reason": "mode-unavailable"})],
"declined": [("refused", {"reason": "declined"})],
"out-of-hours": [("refused", {"reason": "out-of-hours"})],
"silent": [], # never answers: the timeout owner has to act
}
@dataclass
class Handoff:
request: dict
script: list
details: dict
seq: int = 0
pos: int = 0
status: Optional[str] = None
sent: list = field(default_factory=list)
class CallbackStub:
def __init__(self, keys: dict[str, bytes], default: str = "completed",
peer_id: str = "callback-1", email_peer: str = "email"):
if default not in SCENARIOS:
raise ValueError(f"unknown scenario {default!r}")
self.keys, self.peer_id, self.email_peer = keys, peer_id, email_peer
self.default = default
self.handoffs: dict[str, Handoff] = {}
self.received: list[dict] = []
self.lock = threading.Lock()
self._once = Idempotent()
# -- contract endpoint ---------------------------------------------------------
def receive(self, msg: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
if peer != self.email_peer:
return refuse("wrong-peer", "only email may hand off", 403)
return self._once(msg, self._receive)
def _receive(self, msg):
errors = message_errors(msg)
if errors:
return refuse("schema", "; ".join(errors), 400)
with self.lock:
self.received.append(msg)
t, hid = msg["type"], msg["handoff_id"]
if t == "handoff.request":
name = msg["details"].get("scenario", self.default)
if name not in SCENARIOS:
return refuse("unknown-scenario", name, 400)
script = list(SCENARIOS[name])
details = dict(msg["details"])
if script and script[0] == WAIT and all(details.get(k) for k in NEEDED):
script.pop(0) # nothing to negotiate
self.handoffs[hid] = Handoff(msg, script, details)
return 202, {"ok": True, "handoff_id": hid}
h = self.handoffs.get(hid)
if h is None:
return refuse("unknown-handoff", hid, 404)
if t == "handoff.update":
h.details.update(msg["details"])
return 200, {"ok": True}
if t == "handoff.cancel":
if h.status not in TERMINAL:
h.script[h.pos:] = [("cancelled", {"detail": msg.get("reason", "")})]
return 200, {"ok": True}
return refuse("unsupported", t, 400)
# -- simulation ----------------------------------------------------------------
def step(self, handoff_id: Optional[str] = None) -> Optional[dict]:
"""Emit the next status of one handoff (the oldest one with a step due).
Returns the status message sent, or None if nothing is due."""
with self.lock:
h = self._due(handoff_id)
if h is None:
return None
item = h.script[h.pos]
if item == WAIT:
missing = [k for k in NEEDED if not h.details.get(k)]
if not missing:
h.pos += 2 # past the wait, and the step it releases
item = h.script[h.pos - 1]
elif h.status == "negotiating":
return None # still waiting for handoff.update
else:
item = ("negotiating", {"needs": missing})
else:
h.pos += 1
status, extra = item
h.seq += 1
h.status = status
msg = message("handoff.status", h.request["case"], handoff_id=h.request["handoff_id"],
seq=h.seq, status=status, **extra)
h.sent.append(msg)
url = h.request["status_url"]
post(url, msg, (self.peer_id, self.keys[self.email_peer]))
return msg
def run(self, handoff_id: Optional[str] = None, limit: int = 20) -> list[dict]:
"""Step until nothing more is due (terminal, waiting, or silent)."""
out = []
for _ in range(limit):
m = self.step(handoff_id)
if m is None:
break
out.append(m)
return out
def _due(self, handoff_id):
for hid, h in self.handoffs.items():
if handoff_id not in (None, hid) or h.status in TERMINAL:
continue
if h.pos < len(h.script):
return h
return None
def server(self, host="127.0.0.1", port=0):
step = lambda body, peer, rest: (200, {"ok": True, "sent": self.step(body.get("handoff_id"))}) # noqa: E731
return json_server({"/handoffs": self.receive, "/step": step}, self.keys,
frozenset({"/step"}), host, port)