# 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`).