mail-address-semantics/workplans/MAS-WP-0012-single-outbound-path.md
tegwick a5310dd63c Live MX redeployed with mas-live:v0.2-480e69a (one outbound path, v0.2 final); cap counter migrated 4/300
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 13:37:09 +02:00

83 lines
3.2 KiB
Markdown

---
id: MAS-WP-0012
type: workplan
title: "One outbound path and one send cap"
domain: communication
repo: mail-address-semantics
status: finished
owner: claude
topic_slug: coulomb-social
created: "2026-10-19"
updated: "2026-10-19"
origin: residual
origin_ref: MAS-WP-0010
state_hub_workstream_id: "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
```task
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
```task
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.
- **Deployed** 2026-10-19 with `mas-live:v0.2-480e69a`. The live counter
was migrated: 4 of 300 (see MAS-WP-0007).