WhatsApp media survives the provider URL
An inbound image, document, video, or audio message is downloaded at the webhook seam while its provider URL is fresh, checked with SHA-256, and landed under an idempotent key in PRAMAAN-owned S3. The checksum and object receipt are persisted on the existing inbound-message row. Confirmation, target attachment, and Blake handoff are Story-2867 and are deliberately absent here.
The acquisition seam
Meta webhook
→ verify signature + claim dedupe row
→ resolve LIVE bindings (membership mirror + revocation epoch)
no live binding → refuse; no download, no S3 write
→ lock selected binding and re-check status + epoch
→ invoke non-VPC whatsapp-sender Lambda
GET media metadata → immediately stream fresh URL under hard byte cap
→ compute SHA-256 → compare provider checksum when supplied
→ PutObject(channel-media/{sha256("meta:" + provider_message_id)})
→ persist checksum + bucket + key + size + content type + acquired_at
→ only then continue caption disambiguation
Any permanent acquisition error stamps the inbound row failed.
Any transient provider/S3 error returns 500 so Meta retries the same dedupe key.
Acceptance behavior
| Case | Result |
|---|---|
| Fresh media | metadata and CDN reads are back-to-back; bytes are streamed only up to the configured cap |
| URL expires mid-flight | the next attempt fetches new metadata and a new URL; the stale URL is never reused |
| Checksum mismatch / oversize | fail closed; no S3 receipt is recorded |
| Unbound sender | zero acquisition calls and zero S3 writes |
| Revoked binding or epoch | binding row lock plus live epoch check refuses before the sender invocation |
| Ambiguous caption | media is acquired first, then the deterministic matter prompt may span turns without relying on the expiring URL |
Durability and tenancy
Migration d6f4a2c8b190 extends
business.channel_inbound_messages with one complete media
receipt. A check constraint forbids partial receipts, the staff-only
NULL-firm row stays behind the table's existing forced RLS policies,
and a partial acquisition index supports staff receipt lookup. The
downgrade removes the index, constraint, and every added column.
Red-first proof
- The webhook suite first failed because the acquisition seam did not exist; it now proves acquisition-before-disambiguation and zero calls for unbound/revoked senders.
- The streaming test first read a third chunk past the cap; it now stops on the chunk that crosses the limit.
- The sender interface first accepted caller-selected storage details; it now derives both bucket and object key and verifies S3 checksum parameters.
- The deploy canary includes a real S3
PutObject; a planted AccessDenied is red instead of an import-only green. - PostgreSQL integration tests prove staff receipt writes, firm invisibility under RLS, and revocation-epoch refusal before invocation.
Capability dependency
The application path requires the channel-media bucket and
s3:PutObject on the WhatsApp sender role. The canary and
workflow-contract tests are the named detector. The PR must remain
blocked until the linked pramaan-infra twin supplies that bucket and
grant.