Vani's welcome video — outbound media plus a lazy media-ID re-upload
When counsel connects their number, Vani now introduces herself on camera: a 27-second video captioned with the welcome text, as one message. A face turns "a bot texted me" into "someone from my firm's system is here to help".
The interesting engineering is not the send. It is the media-ID lifecycle and the failure path: Meta expires an uploaded media id after 30 days, and a welcome that silently loses its video is exactly the seam AGENTS.md R12 was written about — one day after that rule was added.
What Changed
src/shared/whatsapp/client.py
upload_media() POST /{phone-number-id}/media → reusable media id
send_media() POST /{phone-number-id}/messages, type=video|image|…
_post_message() the ONE /messages retry loop, now shared with send_text
(so the media path cannot drift from the text path's
429/5xx-retry, other-4xx-terminal contract)
src/whatsapp_sender/handler.py (NON-VPC — the only Lambda that can do this)
{"send_media": {...}} S3 read → sha256 → reuse-or-reupload → send
_reupload_reason() the three founder-decided triggers, pure and testable
_run_canary() + a 1-byte ranged GetObject on the REAL asset (R2/R4)
ops.whatsapp_media_assets (migration 0233 — NEW)
asset_key PK logical name, stable across re-cuts (NOT the S3 key)
media_id Meta's reusable uploaded-media id
source_sha256 lowercase hex sha256 of the uploaded SOURCE (DB CHECK)
source_bytes > 0 (DB CHECK)
uploaded_at when Meta minted media_id — the 30-day clock starts here
platform-global: no RLS, no firm_id (the ops.whatsapp_business_numbers precedent)
GRANT SELECT, INSERT, UPDATE to pramaan_app; DELETE revoked
src/domains/channel/welcome_video.py (IN-VPC — the only code that can do THIS)
welcome_video_applies() the founder's trigger table
load/record_welcome_media_asset() the durable half; the sender has no DB
dispatch_welcome_video() span + delivery record + the fall-back contract
src/domains/channel/welcome.py video first; on ANY failure, the text
src/shared/whatsapp/formatter.py the relocked copy (also the caption)
channel.welcome.video new ManualSpanName (ADR-124 registry)
The reader is the SENDER, and that is not a preference
Three facts force it, so the PR states it rather than leaving it implied:
- Lambda's synchronous invoke payload limit is 6 MB and the asset is 6.8 MiB — handing bytes broker → sender is physically impossible, not merely wasteful.
-
The sender is the only Lambda with internet egress to
graph.facebook.com(ADR-167 no-NAT,VpcConfig: null). -
The sender is the only role granted
s3:GetObjectonbrand/vani/*— pramaan-infra Chore-3163, mergedc839df1:simulate-principal-policyreturnsallowedfor that prefix andimplicitDenyforagents/*.
The in-VPC dispatch does what the sender cannot: read and write
ops.whatsapp_media_assets. Postgres is unreachable from a
non-VPC Lambda. The split is the same one Story-2865 uses for inbound
media, run in reverse.
Option C — lazy re-upload, and why the checksum is not optional
Before every send the sender reads the source, hashes it, and re-uploads to Meta only when one of three facts has gone stale:
| Trigger | Reason |
|---|---|
no_record | nothing stored yet — cold start |
expiring | id older than WHATSAPP_MEDIA_REUPLOAD_AFTER_DAYS (default 25, against Meta's 30 — a 5-day margin, config not a literal) |
source_changed | the source sha256 differs from the stored one |
The checksum is not defensive over-engineering. The video was
already replaced once, on 2026-07-30, on the same key:
8daead55… → 97b3052e…. Without the checksum
that swap would have waited out the 25-day threshold before taking
effect — on day one of the asset's existence. With it, re-recording is a
copy operation: aws s3 cp and done, no config edit, no
version bump.
No cron, so nothing to monitor and nothing that can silently stop. Story-3104's shape throughout: prefer the stored value, fall back, heal on next use. There is a fourth, unplanned heal too — if a reused id is rejected by Meta at send time (deleted, or expired early), the sender re-uploads once and retries once. Otherwise a dead id would brick the welcome for the whole threshold window with nothing to correlate it with.
R12 — the video may fail; the welcome may not
dispatch_welcome_video() never raises for a video failure. It
stamps the delivery record's honest terminal state (R5), names the reason
on the channel.welcome.video span's outcome, and
returns False — the caller's signal to send the text welcome.
The span opens pessimistically at dispatch_incomplete, so even
an unhandled crash cannot export a success.
delivered Meta accepted the captioned video
asset_read_failed S3 GetObject failed (AccessDenied, missing key, empty object)
video_upload_failed POST /media rejected the asset
video_send_failed POST /messages rejected it, or the invoke itself died
caption_too_long the interpolated firm name blew Meta's 1024-char cap
dispatch_incomplete the pessimistic open value — never a success
The copy is written so nothing is lost when the video is: some overlap with what Vani says on camera is deliberate, because people do not always tap play and a video that fails to load must not take the instructions with it.
The trigger table
| Event | Video? | Why |
|---|---|---|
| Bind (first ever) | Yes | the point of the ticket |
Bind after a disconnect (rebound=False) | Yes | the code cannot distinguish it from a first bind, and the founder ruled that building "has this user ever been bound" state to prevent a case requiring a deliberate disconnect is not worth it |
Re-enrol while still connected (rebound=True) | No | nothing changed; the short ack is not a welcome |
| Disconnect | No, never | it is a welcome |
| Zero accessible matters | No | the video says "select a matter, then ask your question". 27 seconds of an impossible instruction is worse than no video |
The copy, relocked
POST_BIND_WELCOME_TEMPLATE is now the video's caption — one
message, not a teaser plus a wall of text under a video that may still be
loading. The retired line was To change matter later, say *switch to
{code}*., cut because counsel has not picked a
first matter yet: it taught a future action before the present
one. That was the only reader of first_matter_code, so the
parameter is gone from the template (R1 — the legacy goes in the same
change). The value is still loaded: it is the
has-any-accessible-matter signal that both selects the no-matters variant
and gates the video. The no-matters copy is unchanged.
No green until it has been red
Every claim above has a planted negative that was observed failing before the corresponding test was accepted:
fallback removed (video failure ends the welcome — Bug-3135's exact shape)
→ test_failed_video_still_sends_the_text_welcome FAILED assert 0 == 1
checksum trigger removed from _reupload_reason
→ test_changed_source_checksum_forces_a_reupload FAILED
→ test_reupload_reason_names_each_trigger FAILED
reuse disabled (always re-upload)
→ test_second_enrolment_reuses_the_stored_media_id FAILED
→ test_threshold_is_config_not_a_literal FAILED
→ test_reused_id_rejected_by_meta_heals_in_place FAILED
s3:GetObject grant absent (the Story-2179 shape)
→ test_canary_fails_closed_when_welcome_video_unreadable (ships red-first)
The reuse and re-upload assertions all count the uploads the mocked
Graph API sees — reuse means zero, each trigger means exactly one — so
they cannot pass on a mock's say-so. The durable half (asset record,
delivery records, and the text welcome still landing after an upload
failure) is proved against real Postgres in
tests/integration/test_channel_welcome_video_db.py.
What Stays Out
No live send: nothing in this change contacts a real handset, and the
founder's number has already had enough test traffic for one day. The LIVE
staging proof — enrol, confirm the video arrives, enrol again and confirm
the stored id is reused — is a manual post-merge step, as it is for every
other message this rail sends. The asset itself stays in S3 and is never
committed. Adding channel.welcome.video and
channel.media_reused to the ADR-124 closed registry wants a
pramaan-docs amendment, which this PR flags rather than writes.