Durable delivery records + WhatsApp outbound sender
This story is RFD-AGT-013 T2d-a — the hidden W2 gate:
T2b (command intake), T2e (offboarding notices), and T2g
(disambiguation) all need a working sender primitive and durable
delivery records before any of their tests can prove anything outbound.
It branches off the Story-2847 schema-seed with zero new
migrations — the business.channel_delivery_records
table already exists; this PR writes and reads it.
The two halves
| Piece | Where | What it does |
|---|---|---|
| Sender primitive | src/whatsapp_sender/handler.py +
src/shared/whatsapp/{client,credentials,errors}.py |
NON-VPC Lambda (Terraform shell from Chore-2845, handler contract
src.whatsapp_sender.handler.lambda_handler). Resolves
the bearer token + phone-number-id from the
/pramaan/{env}/whatsapp-* SSM SecureStrings at cold
start (never env, never code), POSTs to the Meta Graph API with
retry/backoff on 429/5xx/transport faults, and returns a
structured result — it never raises for provider failures, so the
caller can stamp the honest terminal state (R5). |
| Delivery records + dispatcher | src/domains/channel/delivery.py |
In-VPC (the ADR-167 egress split: the sender can't reach Postgres, Postgres can't reach Meta). Chunks the body, writes one durable record per provider message (queued → sent with the provider message id, or terminal failed with the Meta error code), and applies status callbacks monotonically + idempotently (delivered/read receipts from the T2a-3 webhook consumer). |
| Delivery formatter (D5/CC-55) | src/shared/whatsapp/formatter.py |
Natural-break chunking (paragraph → line → sentence → word, hard cap 4096) so a long answer arrives as a few short messages, plus the instant static ack — a constant, no LLM, honest about Spike-2889's minutes-long thinking runs. Thinking indicators stay on streamable surfaces (app/mobile) only. |
Why a Lambda hop instead of a direct call
The dispatcher runs in-VPC; graph.facebook.com is third-party SaaS with
no PrivateLink, and the repo rule (ADR-167, the razorpay/ws-authorizer
precedent) forbids NAT for third-party egress. So the network leg lives
on the NON-VPC sender shell and the durable writes stay in-VPC. The
dispatcher invokes the sender once per chunk via
lambda:InvokeFunction (RequestResponse) and stamps records
from the structured response; sender_invoke is an
injectable seam, which is how the tests prove the full record lifecycle
without AWS.
Retry, callbacks, and long-run tolerance
- Provider retry is inside the client: exponential backoff
(configurable via settings), terminal 4xx fails fast with the Meta
error code preserved on the record's
error_code/error_message. - Status callbacks land through
apply_provider_status_callback: rank-monotonic (sent < delivered < read), redelivery-safe, unknown message ids write nothing. The webhook route itself is T2a-3's ingress consumer — this PR ships the durable half it will call. - Spike-2889 seat constraint: the ack is sent at intake before any run
exists (
run_idNULL per the entity contract), and each chunk's record reaches its own terminal state independently — a failed chunk never rolls back a sent one, and failed records can be re-driven minutes later without touching sent ones.
Deploy path
.github/workflows/deploy-whatsapp-sender.yml (manual-first)
publishes the code artifact onto the Chore-2845 Terraform shell — no
IAM, queue, or event-source-mapping writes (one writer per resource).
The smoke is the handler's canary sentinel: it resolves the SSM
credentials (proving the deploy's IAM grant, R2/R4) without sending a
real message. The workflow-contract test
(tests/unit/test_whatsapp_sender_deploy_workflow.py) bans
the empty-payload smoke and infra mutations in that file.
How to verify
# Formatter + mocked-Graph client + handler round-trip + workflow contract uv run pytest tests/unit/test_whatsapp_formatter.py \ tests/unit/test_whatsapp_client.py \ tests/unit/test_whatsapp_sender_handler.py \ tests/unit/test_whatsapp_sender_deploy_workflow.py # Durable record lifecycle + monotonic callbacks (Docker test DB) make test-db bash scripts/run-with-test-db-env.sh uv run pytest \ tests/integration/test_channel_delivery_db.py # LIVE proof (post-merge, manual — see the PR body for exact steps): # invoke pramaan-whatsapp-sender-staging with a real body to the registered # test recipient +918595177060 and confirm delivery on the device.
Scope boundaries
- No new migrations, no OpenAPI churn (no route/DTO changes).
- No webhook receiver here — ingress HMAC + the status-callback route are T2a-3; this PR provides the callback application function it calls.
- No behavior messages (refusals, menus, command acks) — T2b/T2e/T2g
own those kinds; this PR ships
answerandackonly.