- mas_core: P2-maximal parser with required_profile, generator, statute schema + semantic lint + defaults-only inheritance, exact-host discovery with typed failures and same-origin redirects, pure policy resolver - mas_conformance: vendored spec fixtures (drift-tested) + property and statute cases - mas CLI: parse / explain / discover / lint-statute / test - mas-gateway: generic mail -> MAS event -> HTTP (MTA pipe + webhook) - examples: static great-service.example statute, reference ticket service - docs/experiment-review.md: first-pass findings; live MTA test still open Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 623586@bnt-lap001 Assistant-Session: 10b30928-5b3b-4c11-9934-5ecca07367d8
226 lines
11 KiB
Python
226 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Reference ticket service for the MAS v0.1 experiment.
|
|
|
|
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
|
|
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)
|
|
|
|
Resource addresses are correlation identifiers, not credentials: knowing
|
|
one lets you send mail to the ticket, nothing more. Admin endpoints here are
|
|
unauthenticated and meant for local experiments only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import secrets
|
|
import smtplib
|
|
import sqlite3
|
|
import threading
|
|
from email.message import EmailMessage
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Callable, Optional
|
|
|
|
from mas_core.generator import resource_local_part
|
|
|
|
RESOURCE_TYPE = "ticket"
|
|
INTAKE_TARGET = "ticket"
|
|
_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford base32, no I L O U
|
|
|
|
|
|
def default_ticket_id() -> str:
|
|
"""Non-guessable-preferred, per the great-service statute."""
|
|
return "GS-" + "".join(secrets.choice(_ALPHABET) for _ in range(10))
|
|
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS tickets (
|
|
id TEXT PRIMARY KEY, domain TEXT NOT NULL, status TEXT NOT NULL,
|
|
requester TEXT, subject TEXT, requested_interaction TEXT, created_seq INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, disposition TEXT NOT NULL,
|
|
recipient TEXT, facet TEXT, sender TEXT, subject TEXT, message_id TEXT, body TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS outbox (
|
|
seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, mail_from TEXT, mail_to TEXT,
|
|
subject TEXT, body TEXT, in_reply_to TEXT, sent INTEGER DEFAULT 0
|
|
);
|
|
"""
|
|
|
|
|
|
class TicketService:
|
|
def __init__(self, db_path: str = ":memory:", id_factory: Callable[[], str] = default_ticket_id,
|
|
smtp: Optional[tuple[str, int]] = None, closed_notice: bool = True):
|
|
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
|
self.db.row_factory = sqlite3.Row
|
|
self.db.executescript(SCHEMA)
|
|
self.lock = threading.Lock()
|
|
self.new_id = id_factory
|
|
self.smtp = smtp
|
|
self.closed_notice = closed_notice
|
|
|
|
# -- event handling -------------------------------------------------------------
|
|
|
|
def handle(self, event: dict) -> tuple[int, dict]:
|
|
mas, msg = event.get("mas", {}), event.get("message", {})
|
|
with self.lock, self.db:
|
|
if not mas.get("valid"):
|
|
return 422, {"status": "not-mas", "action": "ignored"}
|
|
if not mas.get("namespace_confirmed"):
|
|
# No validated statute: we refuse to act on unconfirmed semantics.
|
|
return 422, {"status": "namespace-unconfirmed", "action": "ignored",
|
|
"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:
|
|
return self._create(event, mas, msg)
|
|
return 404, {"status": "not-handled", "action": "ignored"}
|
|
|
|
def _create(self, event, mas, msg):
|
|
interaction = mas.get("interaction")
|
|
failures = {f["code"] for f in mas.get("failures", [])}
|
|
honored = interaction if interaction and not failures else None
|
|
tid = self.new_id()
|
|
domain = mas["domain"]
|
|
self.db.execute("INSERT INTO tickets VALUES (?,?,?,?,?,?,NULL)",
|
|
(tid, domain, "open", msg.get("from"), msg.get("subject"), honored))
|
|
self._store(tid, "attached", event, mas, msg)
|
|
address = f"{resource_local_part(RESOURCE_TYPE, tid)}@{domain}"
|
|
lines = [f"Your request is now ticket {tid}.",
|
|
f"Reply to {address} to add to it; no reference in the subject is needed."]
|
|
if honored:
|
|
lines.append(f"You asked to continue via '{honored}'. We will try to honour that preference.")
|
|
elif interaction:
|
|
lines.append(f"The requested continuation mode '{interaction}' is not available "
|
|
f"({', '.join(sorted(failures))}); we will continue by email.")
|
|
self._reply(tid, address, msg, msg.get("subject") or "Your request", "\n".join(lines))
|
|
return 201, {"status": "created", "ticket_id": tid, "resource_address": address,
|
|
"requested_interaction": honored}
|
|
|
|
def _to_resource(self, event, mas, msg):
|
|
tid = mas["resource_id"]
|
|
row = self.db.execute("SELECT * FROM tickets WHERE id=? AND domain=?",
|
|
(tid, mas["domain"])).fetchone()
|
|
if row is None:
|
|
self._store(None, "quarantined-unknown", event, mas, msg)
|
|
return 202, {"status": "unknown-resource", "action": "quarantined, no reply"}
|
|
if row["status"] == "closed":
|
|
self._store(tid, "archived-after-close", event, mas, msg)
|
|
notified = False
|
|
if self.closed_notice and msg.get("from") and msg.get("from") == row["requester"]:
|
|
address = f"{resource_local_part(RESOURCE_TYPE, tid)}@{row['domain']}"
|
|
notified = self._reply(tid, address, msg, msg.get("subject") or "Ticket closed",
|
|
f"Ticket {tid} is closed. Your message was archived and did "
|
|
f"not reopen it. Write to {INTAKE_TARGET}@{row['domain']} "
|
|
f"for a new request.")
|
|
return 200, {"status": "closed", "ticket_id": tid, "action": "archived",
|
|
"mutated": False, "notified": notified}
|
|
self._store(tid, "attached", event, mas, msg)
|
|
return 200, {"status": "correlated", "ticket_id": tid, "facet": mas.get("facet")}
|
|
|
|
def _store(self, tid, disposition, event, mas, msg):
|
|
self.db.execute(
|
|
"INSERT INTO messages (ticket_id, disposition, recipient, facet, sender, subject, message_id, body)"
|
|
" VALUES (?,?,?,?,?,?,?,?)",
|
|
(tid, disposition, event.get("recipient"), mas.get("facet"), msg.get("from"),
|
|
msg.get("subject"), msg.get("message_id"), msg.get("text")))
|
|
|
|
def _reply(self, tid, mail_from, msg, subject, body) -> bool:
|
|
to = msg.get("from")
|
|
auto = (msg.get("auto_submitted") or "no").lower() != "no"
|
|
bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk")
|
|
if not to or auto or bulk:
|
|
return False # never auto-reply to automated mail
|
|
if not subject.lower().startswith("re:"):
|
|
subject = "Re: " + subject
|
|
self.db.execute("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to)"
|
|
" VALUES (?,?,?,?,?,?)", (tid, mail_from, to, subject, body, msg.get("message_id")))
|
|
if self.smtp:
|
|
self._send(mail_from, to, subject, body, msg.get("message_id"))
|
|
return True
|
|
|
|
def _send(self, mail_from, to, subject, body, in_reply_to):
|
|
m = EmailMessage()
|
|
m["From"], m["To"], m["Subject"] = mail_from, to, subject
|
|
m["Auto-Submitted"] = "auto-replied"
|
|
if in_reply_to:
|
|
m["In-Reply-To"] = m["References"] = in_reply_to
|
|
m.set_content(body)
|
|
with smtplib.SMTP(*self.smtp) as s:
|
|
s.send_message(m)
|
|
|
|
# -- admin / inspection ----------------------------------------------------------
|
|
|
|
def close(self, tid: str) -> bool:
|
|
with self.lock, self.db:
|
|
return self.db.execute("UPDATE tickets SET status='closed' WHERE id=?", (tid,)).rowcount == 1
|
|
|
|
def ticket(self, tid: str) -> Optional[dict]:
|
|
row = self.db.execute("SELECT * FROM tickets WHERE id=?", (tid,)).fetchone()
|
|
if row is None:
|
|
return None
|
|
msgs = self.db.execute("SELECT * FROM messages WHERE ticket_id=? ORDER BY seq", (tid,)).fetchall()
|
|
return {**dict(row), "messages": [dict(m) for m in msgs]}
|
|
|
|
def outbox(self) -> list[dict]:
|
|
return [dict(r) for r in self.db.execute("SELECT * FROM outbox ORDER BY seq")]
|
|
|
|
def quarantine(self) -> list[dict]:
|
|
return [dict(r) for r in self.db.execute(
|
|
"SELECT * FROM messages WHERE disposition='quarantined-unknown' ORDER BY seq")]
|
|
|
|
|
|
def make_server(service: TicketService, host="127.0.0.1", port=8025) -> ThreadingHTTPServer:
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
|
if self.path == "/mas-events":
|
|
try:
|
|
event = json.loads(body.decode())
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return self._json(400, {"error": "invalid JSON"})
|
|
return self._json(*service.handle(event))
|
|
parts = self.path.strip("/").split("/")
|
|
if len(parts) == 3 and parts[0] == "tickets" and parts[2] == "close":
|
|
return self._json(200 if service.close(parts[1]) else 404, {"ticket_id": parts[1]})
|
|
self._json(404, {"error": "not found"})
|
|
|
|
def do_GET(self):
|
|
parts = self.path.strip("/").split("/")
|
|
if len(parts) == 2 and parts[0] == "tickets":
|
|
t = service.ticket(parts[1])
|
|
return self._json(200 if t else 404, t or {"error": "not found"})
|
|
if parts == ["outbox"]:
|
|
return self._json(200, service.outbox())
|
|
self._json(404, {"error": "not found"})
|
|
|
|
def _json(self, status, obj):
|
|
out = json.dumps(obj).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(out)))
|
|
self.end_headers()
|
|
self.wfile.write(out)
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
return ThreadingHTTPServer((host, port), Handler)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--db", default="tickets.sqlite3")
|
|
ap.add_argument("--listen", default="127.0.0.1:8025")
|
|
ap.add_argument("--smtp", metavar="HOST:PORT", help="send replies via this SMTP relay")
|
|
a = ap.parse_args()
|
|
smtp = (a.smtp.rsplit(":", 1)[0], int(a.smtp.rsplit(":", 1)[1])) if a.smtp else None
|
|
h, p = a.listen.rsplit(":", 1)
|
|
print(f"ticket service on http://{a.listen}/mas-events")
|
|
make_server(TicketService(a.db, smtp=smtp), h, int(p)).serve_forever()
|