Story-3149

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:

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:

TriggerReason
no_recordnothing stored yet — cold start
expiringid older than WHATSAPP_MEDIA_REUPLOAD_AFTER_DAYS (default 25, against Meta's 30 — a 5-day margin, config not a literal)
source_changedthe 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

EventVideo?Why
Bind (first ever)Yesthe point of the ticket
Bind after a disconnect (rebound=False)Yesthe 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)Nonothing changed; the short ack is not a welcome
DisconnectNo, neverit is a welcome
Zero accessible mattersNothe 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.