diff --git a/examples/ticket_service/ticket_service.py b/examples/ticket_service/ticket_service.py index 6dfc5f5..0489b31 100644 --- a/examples/ticket_service/ticket_service.py +++ b/examples/ticket_service/ticket_service.py @@ -9,6 +9,9 @@ proves the canonical lifecycle: C closed ticket=ID@DOMAIN (closed) -> archived, ticket not mutated, one notice D unknown ticket=NOPE@DOMAIN -> quarantined, no reply (no backscatter) +Ticket ids match case-insensitively (`ticket=gs-id@` finds `GS-ID`), because +forwarders and mailing lists lower-case local-parts in practice. + 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. @@ -106,12 +109,14 @@ class TicketService: "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() + # Ids match case-insensitively: forwarders and list servers lower-case + # local-parts (SCP-7). From here on use the id as issued. + row = self.db.execute("SELECT * FROM tickets WHERE id=? COLLATE NOCASE AND domain=?", + (mas["resource_id"], mas["domain"])).fetchone() if row is None: self._store(None, "quarantined-unknown", event, mas, msg) return 202, {"status": "unknown-resource", "action": "quarantined, no reply"} + tid = row["id"] if row["status"] == "closed": self._store(tid, "archived-after-close", event, mas, msg) notified = False @@ -169,13 +174,15 @@ class TicketService: 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 + return self.db.execute("UPDATE tickets SET status='closed' WHERE id=? COLLATE NOCASE", + (tid,)).rowcount == 1 def ticket(self, tid: str) -> Optional[dict]: - row = self.db.execute("SELECT * FROM tickets WHERE id=?", (tid,)).fetchone() + row = self.db.execute("SELECT * FROM tickets WHERE id=? COLLATE NOCASE", (tid,)).fetchone() if row is None: return None - msgs = self.db.execute("SELECT * FROM messages WHERE ticket_id=? ORDER BY seq", (tid,)).fetchall() + msgs = self.db.execute("SELECT * FROM messages WHERE ticket_id=? ORDER BY seq", + (row["id"],)).fetchall() return {**dict(row), "messages": [dict(m) for m in msgs]} def outbox(self) -> list[dict]: diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 9e05a49..cd8ade8 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -83,6 +83,19 @@ def test_canonical_lifecycle(env): assert len(svc.outbox()) == before and len(svc.quarantine()) == 1 +def test_lower_cased_id_correlates(env): + # IONOS forwards and Google Groups members arrive lower-cased (MAS-WP-0003 T04) + svc, send, *_ = env + send(f"ticket!callback@{D}", "request") + d = send(f"ticket=gs-41872+comments@{D}", "via a mailing list") + assert d.response == {"status": "correlated", "ticket_id": "GS-41872", "facet": "comments"} + assert len(svc.ticket("gs-41872")["messages"]) == 2 + assert svc.close("gs-41872") + d = send(f"ticket=gs-41872@{D}", "after close") + assert d.response["ticket_id"] == "GS-41872" and d.response["notified"] is True + assert svc.outbox()[-1]["mail_from"] == f"ticket=GS-41872@{D}" # replies use the issued id + + def test_send_cap_keeps_replies_in_outbox(env): svc, send, *_ = env sent = [] diff --git a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md index d25f8ec..b345e7f 100644 --- a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md +++ b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md @@ -52,7 +52,7 @@ fixtures and update the parser, policy and schema. ```task id: MAS-WP-0005-T03 -status: todo +status: progress priority: high state_hub_task_id: "7c19e951-7ab4-580c-9e4c-9fead9ee946e" ``` @@ -62,6 +62,14 @@ and a list post was lost to its ticket. Make the reference ticket service issue and match ids case-insensitively. Crockford base32 is case-insensitive by design. Verify on the live MX by repeating the Google Groups post. +- 2026-10-18: implemented. Ticket lookup uses `COLLATE NOCASE` (also in the + `ticket()`/`close()` admin calls). Once found, the service stores, + replies and reports under the id as issued, so replies keep coming From + `ticket=GS-…@`. Test `test_lower_cased_id_correlates` passes, as do all + 137 tests. Deployed as `mas-live:wp0005-20261018`. A dry check on a copy + of the live DB resolves `gs-85qj8wc09p` to `GS-85QJ8WC09P`. Pending: + live Google Groups post. + ## v0.2 drafts: SCP-6 and the SCP-7 statute field ```task