Complete EMAIL-WP-0004 transactional invitation mail delivery.
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:
parent
5d1c5e1c5c
commit
04897a149f
18 changed files with 1269 additions and 117 deletions
96
docs/EMAIL-WP-0004-T04-failure-evidence.md
Normal file
96
docs/EMAIL-WP-0004-T04-failure-evidence.md
Normal 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`).
|
||||
Loading…
Add table
Add a link
Reference in a new issue