Complete EMAIL-WP-0004 transactional invitation mail delivery.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Ship the railiance01 deploy package (OpenBao/ESO custody, NetworkPolicy,
probes), provider failure classification and suppression, T04 unit and live
proof, and non-secret NK-WP-0024 hand-back evidence. Workplan finished.
This commit is contained in:
tegwick 2026-08-12 13:32:11 +02:00
parent 5d1c5e1c5c
commit 04897a149f
18 changed files with 1269 additions and 117 deletions

View file

@ -0,0 +1,96 @@
# EMAIL-WP-0004-T04 — invitation delivery failure evidence
**Date:** 2026-08-12
**Cluster:** railiance01
**Service:** `http://email-connect.email-connect.svc.cluster.local:8080`
**Image:** `forgejo.coulomb.social/coulomb/email-connect@sha256:a9de1994eea88502ec30ba7c2fc029ec901d873140d51e11d2c50e363a0578b1`
**Unit tests:** `PYTHONPATH=src python3 -m pytest tests/test_transactional.py` — **9 passed**
This note is the non-secret hand-back for **NK-WP-0024**. No SMTP passwords,
ingest tokens, or raw provider exception text are included.
## Contract for user-engine
| HTTP | `error` / `status` | `retryable` | Meaning for outbox |
| --- | --- | --- | --- |
| 401 | `unauthorized` | — | Wrong or missing bearer; fail closed |
| 400 | `template_not_allowed` | — | Event type/source not on allow-list |
| 400 | `invalid_recipient` | — | Address failed local validation |
| 400 | `idempotency_key_mismatch` | — | `Idempotency-Key` ≠ event `id` |
| 400 | `recipient_suppressed` | — | Address on suppression list; do not retry as send |
| 400 | `invitation_id_required` | — | Missing invitation id |
| 200 | `status=duplicate` + `reference` | — | Same event id already accepted; safe replay |
| 202 | `status=accepted` + `reference` + `event_id` | — | Provider accepted message; **not** inbox delivery |
| 503 | `provider_timeout` / `temporary_deferral` / `provider_unavailable` | `true` | Bounded retry |
| 422 | `permanent_rejection` | `false` | Do not retry; surface failure evidence |
### Evidence ceilings (must not become authorization)
- Invitation accept: `evidence_ceiling=provider_accepted` — transport only.
- Registration consume/cancel: `authorization=false`, ceilings
`mailbox_challenge_consumed` / `mailbox_challenge_canceled`.
- `assurance.mailbox_control=true` is **channel evidence**, not identity proof
and not a flex-auth decision.
Mailbox ownership challenges and provider acceptance **must never** alter
user-engine authorization decisions.
## Unit matrix (local)
| Case | Result |
| --- | --- |
| Template allow-list denial | `400 template_not_allowed` |
| Invalid recipient | `400 invalid_recipient` |
| Idempotency mismatch | `400 idempotency_key_mismatch` |
| Auth denial | `401 unauthorized` |
| Duplicate event id | `200 duplicate` + same `reference`; provider called once |
| Resend (`family_invitation.resent`) | New event id → second provider send |
| Suppression | `400 recipient_suppressed`; no provider call |
| Provider timeout / temporary / unavailable | `503` + `retryable=true`, redacted codes |
| Permanent rejection | `422` + `retryable=false` |
| SMTPRecipientsRefused → permanent | ProviderError mapping unit-tested |
| Redaction | Response JSON has no password/SMTP host/exception text |
| Mailbox evidence ≠ authz | `authorization=false` on consume/cancel |
## Live matrix (railiance01, from `user-engine` pod)
| Case | HTTP | Body (redacted) |
| --- | --- | --- |
| Unauth | 401 | `{"error":"unauthorized"}` |
| Template deny | 400 | `{"error":"template_not_allowed"}` |
| Invalid recipient | 400 | `{"error":"invalid_recipient"}` |
| Idempotency mismatch | 400 | `{"error":"idempotency_key_mismatch"}` |
| Accept (to `forgejo@coulomb.social`) | 202 | `status=accepted`, `evidence_ceiling=provider_accepted`, `event_id` set |
| Duplicate same event id | 200 | `status=duplicate`, same `reference` |
| Resend new event id | 202 | `status=accepted` |
| Bad domain `nobody@invalid.invalid` | 422 | `{"error":"permanent_rejection","retryable":false}` |
| Suppressed `suppressed@example.test` | 400 | `{"error":"recipient_suppressed"}` (seeded in SQLite for smoke) |
| Redaction | — | No `password` / `smtp.ionos` in any body |
Negative path (unchanged from T03): pods outside `user-engine` cannot reach
TCP 8080 (Connection refused).
## Non-secret references for NK-WP-0024
- Cluster-local base URL:
`http://email-connect.email-connect.svc.cluster.local:8080`
- Paths: `POST /v1/send`, `POST /v1/registration-verifications`,
`…/consume`, `…/cancel`
- Auth: shared ingest bearer (OpenBao
`platform/workloads/email-connect/transactional` field
`EMAIL_CONNECT_INGEST_TOKEN` only — never SMTP fields in user-engine)
- Idempotency: header `Idempotency-Key` = event `id`
- Allowed send types: `family_member.invited`, `family_invitation.resent`
- Catalog route: `email-connect-transactional` (ops-warden)
## Temporary deferral / timeout note
Live temporary deferral depends on provider behavior and was covered by unit
classification (`temporary_deferral`, `provider_timeout` → 503 retryable).
Live permanent path was observed via invalid domain → `422 permanent_rejection`.
## Follow-ups (out of T04)
- Mount ingest token into user-engine runtime for production outbox (NK-WP-0024).
- Operator API to manage suppressions (currently SQLite; smoke seeded manually).
- Daily send-quota tiers deferred (`DECISIONS.md`).