Summary — RFC: Register Campaign Type to CAA (Chatbot Autoreply), Backend
Digest of
rfc-wa-campaign-register-campaign-type-to-caa-be.md(backend RFC, status in-review / QontakRFC, target 2026-Q3, last updated 2026-07-13, rev4). Owner: Revenue Chat-2. Latestrfc-reviewerscore: 8.0 / Strong / PROCEED with notes (…-be-review.md, assessed against rev1 — rev3/rev4 resolve its array-ordering and N+1 findings; a re-score is pending). This summary is a reading aid; the draft is the source of truth.
TL;DR
The chatbot (autoreply) is fed by the CAA webhook (custom_agent_allocation), which today carries
only the customer's inbound message. This RFC (backend, hub_core only) enriches the CAA payload with
a single, consistent campaign_context envelope — { trigger, broadcasts: [ … ] } — populated by
two cases:
- Interactive-button reply — the customer tapped a quick-reply button of a specific broadcast
(detected the same way the platform records
Models::MessageBroadcastInteractiveLog) →trigger = "interactive_button",broadcasts= that one broadcast (incl.replied_button). - Any other reply →
trigger = "room_broadcasts",broadcasts= all campaign broadcasts in the room within 30 days (newest-first, cap 20).
Consistency (rev4): one envelope, same 10-field per-broadcast schema in both cases, and it is a
superset of the legacy last_campaign (which is kept byte-identical but deprecated, with a
field mapping). No schema migration — all fields derived at read time; read-only + fail-safe; case-2
resolution batched (no N+1). Gated per-org by a flag; rollback = flag flip.
Problem & Goal
- Today: CAA sends only inbound messages; a prior campaign is invisible to the bot. The existing
last_campaignhash is room-origin-only,last_*-prefixed, thin, and never setslast_sent_at. - Goal: on a reply, put the relevant broadcast context inline in one consistent CAA envelope — the precise tapped broadcast (case 1) or the whole room's campaign set (case 2) — no secondary lookup.
- Success criteria: one
campaign_contextenvelope; same element schema both cases; case 1 → 1 broadcast +interactive_button; case 2 → all in-window +room_broadcasts; empty envelope when no context (flag ON) / absent (flag OFF, byte-compatible); UC4 backdated; no migration; batched (no N+1);last_campaignunchanged.
Out of scope: chatbot-side consumption (CAA-S05); removing last_campaign (deprecated here,
removed later — Open Q#10); retroactive registration; the message_interaction webhook; modifying the
interactive-log / reply-recording writes; BUTTON_REPLY / order-message BUTTON (fall to case 2).
What gets built (hub_core only — additive, read-time, no migration)
Schema — none (derived at read time).
New code
| Artifact | Role |
|---|---|
Whatsapp::Services::InteractiveBroadcastReply | case-1 detector: buttons[0].type=='BUTTON' + reply_id → parent → raw_message['message_broadcast_id'] → {broadcast_id, replied_button}, else nil |
Repositories::Rooms::CampaignContexts | orchestrates cases 1/2 → the envelope; case-2 window + batched MessageBroadcast.where(id: ids).includes(:message_template), newest-first, cap 20 |
Whatsapp::Services::CampaignMessageTypeResolver | compose message_type + message_category |
Whatsapp::Services::CampaignContentSerializer | message_content_text (no existing serializer) |
Entities/Builders::Webhooks::CampaignContext | the 10-field per-broadcast object (identity + PRD content + replied_button) |
Extended / reused
| Contract | Change |
|---|---|
CustomAgentAllocation#build_message | extended — merge the additive campaign_context envelope (flag-gated); last_campaign unchanged |
last_campaign (get_last_campaign) | reused unchanged + deprecated — superseded by campaign_context (field mapping in Decision 6) |
| Interactive-log condition + resolution / reply-window queries | reused as patterns — workers/model untouched |
Webhooks::CaaWorker, Creates::Customer fan-out | reused |
feature flag register_campaign_type_to_caa | new (default OFF) |
Key design decisions (D1–D7)
| # | Decision | Summary |
|---|---|---|
| D1 | Storage | Derive fields at read time — no column/migration. |
| D2 | Windows | Case 1: single via reply_id → parent → message_broadcast_id (pruned). Case 2: all via room window (reply-30d..reply). |
| D3 | Case discriminator | Reuse the interactive-log condition (buttons[0]=='BUTTON' + resolvable broadcast parent) → case 1; else case 2. |
| D4 | Trigger & timing | Enrich synchronously in build_message — CAA fires before the async interactive-log + reply workers. |
| D5 | Content-to-text | New stateless CampaignContentSerializer; buttons ignored in the text. |
| D6 | Consistent payload | Single campaign_context envelope { trigger, broadcasts:[…] }, one canonical 10-field per-broadcast schema (superset of last_campaign); last_campaign preserved + deprecated (field mapping). |
| D7 | Case-2 cost | All in-window campaigns, batched resolution (no N+1), newest-first, cap 20. |
Canonical broadcasts[] element: broadcast_id, broadcast_name, template_id, template_name,
message_type, message_category, campaign_sent_at, message_body, message_content_text,
replied_button (case 1 only). last_campaign → campaign_context mapping: last_campaign_id→broadcast_id,
last_campaign_name→broadcast_name, last_sent_template_id→template_id, last_sent_template_name→template_name,
last_sent_at→campaign_sent_at (+ the 5 new fields).
Key flow
Customer replies
→ Messages::Creates::Customer#call
├─ (:60-63 / :295 async) interactive-log + reply-record workers (linkage reused, not modified)
└─ (:40-43 sync, no active agent) deliver_caa_webhook!
→ CustomAgentAllocation#build_message (flag register_campaign_type_to_caa ON?)
→ Rooms::CampaignContexts(message, room)
→ InteractiveBroadcastReply: buttons[0]=='BUTTON' & reply_id→parent→message_broadcast_id?
yes (CASE 1) → 1 broadcast + replied_button → { trigger:'interactive_button', broadcasts:[1] }
no (CASE 2) → room window (is_campaign, 30d) → batched resolve, cap 20
→ { trigger:'room_broadcasts', broadcasts:[N] }
none → { trigger:null, broadcasts:[] }
→ merge campaign_context (envelope); leave last_campaign → enqueue CaaWorker → HTTPS POST
Skip: bot stopped (agent assigned) · flag OFF → campaign_context absent (byte-compatible with today).
Rollout, rollback & observability
- Rollout: merge BE (flag OFF) → seed flag → enable internal/design-partner → chatbot ships CAA-S05
(reads
campaign_context) → enable globally → (later) deprecate/removelast_campaign(Open Q#10). - Feature flag:
register_campaign_type_to_caa(default OFF, per-org). Kill-switch = flip OFF →campaign_contextstops (last_campaignunaffected); no data to unwind. - Rollback trigger:
caa_campaign_context_skipped{reason="error"}> 1% of CAA fires, or any CAA delivery-rate regression. - Observability:
caa_campaign_context_enriched{trigger,count,truncated}+caa_campaign_context_skipped{reason};[caa campaign enrichment]log (no raw body text).
Status & open items
Execution plan: 8 ordered chunks (seed flag → InteractiveBroadcastReply → content serializer →
type-resolver + entity/builder (10 fields) → CampaignContexts repo (envelope, batched, cap 20) →
extend CustomAgentAllocation → observability → full regression). Ready for agent execution: no.
Open questions:
| # | Item | Type | Deadline |
|---|---|---|---|
| 1 | Confirm case-2 eligible set + newest-first cap 20 | PM/Chatbot | Before GA |
| 2 | Verify/add composite index for the case-2 room lookup; EXPLAIN pruning | Verification | Before chunk 5 merge |
| 3 | Confirm order-msg BUTTON / BUTTON_REPLY fall to case 2 | PM/Chatbot | Before Stage 2 |
| 4 | Header/footer serialization: PRD table vs CAA-S03 AC (RFC follows AC) | PM sign-off | RFC sign-off |
| 6 | Infosec: mask body/content text in the webhook event log? | Infosec | RFC sign-off |
| 9 | Chat-1 accepts the additive campaign_context envelope | Coordination | RFC sign-off |
| 10 | last_campaign deprecation window → remove in a follow-up | Coordination | Post-GA |
(Also Open Q#5 — Services::Preference fail-open-as-OFF, verified during chunk 6.)
Dependencies
- Chatbot / Automation squad: consume
campaign_context(CAA-S05 / QC-23109). Blocking for product value, not BE delivery. - Chat-1 / Inbox: owns the CAA webhook contract; accepts the additive envelope; co-owns
last_campaigndeprecation. - Infra/Platform: seed
register_campaign_type_to_caa(default OFF).
Related: PRD 51233095746 · Epic
QC-22896 · Stories QC-23105…QC-23109 · sibling RFC
hub_core/docs/rfcs/rfc-wa-campaign-product-card-carousel-be.md.
Revision history in the draft's §6 Comment logs (v1 → rev4, 2026-07-13).