mail-address-semantics/workplans/MAS-WP-0012-single-outbound-path.md
tegwick 8de8fddf37 MAS-WP-0012 finished: one outbound path and one send cap
mas_modes.mailer.Mailer is the only outbound path (own SQLite + lock),
shared by the ticket service and both EmailMode roles. Over the cap mail
is held and reported (send-cap-reached, held); held mail is purged after
email-held-duration-max (7d) and not stored beyond emails-held-storage-max
(100MB). The ticket service's legacy outbox is imported once so the live
count carries over (checked on a copy of the live DB: 4 sent). Entrypoint
passes the cap and both limits.

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 12:44:16 +02:00

3.2 KiB

id type title domain repo status owner topic_slug created updated origin origin_ref state_hub_workstream_id
MAS-WP-0012 workplan One outbound path and one send cap communication mail-address-semantics finished claude coulomb-social 2026-10-19 2026-10-19 residual MAS-WP-0010 be2db53f-9acc-5bf0-a6dc-fb99cbc8e46b

One outbound path and one send cap

Risk C in history/2026-10-19T090725Z-scope-vs-intent.md. With both roles in one deployment, the ticket service and mas_modes.mailer each keep their own outbox and cap. Total outbound can then reach about twice the configured cap. They also act differently over the cap: the ticket service keeps replies unsent, while the mailer refuses with send-cap-reached. Principle 10 says "caps how much it can send in total".

Route all mail through the mailer

id: MAS-WP-0012-T01
status: done
priority: medium
state_hub_task_id: "5d9fefac-684d-5fe0-bead-f664952047e0"

Make mas_modes.mailer.Mailer the only outbound path. The ticket service then sends through a Mailer it is given, sharing the DB or an outbox service, instead of its own outbox table and cap. Settle one behavior over the cap, and record it in CONTRACT.md §6. Keep the ticket service's /outbox inspection working, and keep the deployment's --max-sent semantics.

Test the shared cap

id: MAS-WP-0012-T02
status: done
priority: medium
state_hub_task_id: "431cc528-cf9f-5991-8044-7cbab2ac0021"

Add a test that a deployment with both roles stops at exactly N sends in total, and that the live entrypoint still passes MAS_MAX_SENT to the single cap.

Outcome

  • Founder interview (2026-10-19). Over the cap, mail is held and reported. Held mail is purged after email-held-duration-max (default 7d) and not stored beyond emails-held-storage-max (default 100MB). The live counter is migrated once at start.
  • mas_modes.mailer.Mailer is the one outbound path: its own SQLite file and lock, principle 10, one lifetime cap, holding with a purge on every send and purge_held(), a storage limit, import_legacy() with a once-only marker, and parse_duration / parse_size.
  • Ticket service. It sends through a Mailer it is given, or builds one. /outbox shows the shared outbox (ticket_id = case ref). Its legacy outbox table is imported once and then left alone. New CLI options: --outbox-db (default <db>-outbox.sqlite3), --email-held-duration-max and --emails-held-storage-max. Over the cap the closed notice reports notified: false.
  • EmailMode takes the shared Mailer. mail.send over the cap answers send-cap-reached with held. CONTRACT.md §5 and §6 are updated.
  • Deploy. The entrypoint passes MAS_MAX_SENT (300) and the two hold limits (MAS_EMAIL_HELD_DURATION_MAX, MAS_EMAILS_HELD_STORAGE_MAX). The migration was checked on a copy of the live DB: 9 legacy rows, 4 sent, carried over; no second import on restart.
  • Tests. tests/test_outbound.py (15): exactly N sends across both roles, purge by age, storage limit, sent mail never purged, the legacy migration, parsers, entrypoint flags.
  • Not deployed. The live MX still runs v0.2-a95f848; this takes effect on the next redeploy.