Story-2851

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

PieceWhereWhat 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

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