Skip to main content

RFC: WA Campaign — Register Campaign Type to CAA (Chatbot Autoreply), Backend

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. It is also agent-execution-ready: §1 PRD-to-Schema Derivation, §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan + Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.

Metadata

FieldValueNotes
Statusin-review (Qontak RFC governance: RFC)content-repo linter enum; RFC→in-review remap
OwnerRevenue Chat-2Squad owning this feature
Author(s)Isna Rahmatul KhoirPrimary author (engineer named in PRD)
ReviewersChat-1 (Inbox/CAA infra), Chatbot/Automation squad, WA Cloud TeamTech reviewers across affected squads
Approver(s)TBD — tech lead + infosec approverRequired before AGREED
Submitted Date2026-07-07ISO-8601
Last Updated2026-07-13ISO-8601
Target Release2026-Q3Per PRD (26Q3)
Related DocumentsPRD (Confluence) · Epic QC-22896 · Sibling RFC — Product Card Carousel BE (hub_core/docs/rfcs/rfc-wa-campaign-product-card-carousel-be.md, hub_core repo)PRD v1.1 FINAL
DiscussionTBDSlack channel / thread URL

Type: backend Sub-type: new-feature

Sections at a Glance

  1. Overview (incl. §1 PRD-to-Schema Derivation)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequence Diagrams → DDL → APIs → integrity/async)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Scope note (rev4, 2026-07-13): two behaviours + one consistent payload envelope.

  • Two cases (rev3): (1) reply to an interactive (quick-reply) broadcast — detected the same way the platform records Models::MessageBroadcastInteractiveLog — → send only that button's broadcast context; (2) any other reply → send all campaign-broadcast contexts in the room (30-day window).
  • Consistent format (rev4): both cases (and the legacy last_campaign) are unified into a single campaign_context envelope with one canonical, fully-qualified per-broadcast schema (see Decision 6 + §2.4). Case 1 = a 1-element broadcasts array; case 2 = an N-element array.

When a WhatsApp campaign message is sent to a customer it appears as an outbound message in the inbox room, but it is not registered to CAA (Custom Agent Allocation — the webhook that feeds the chatbot / autoreply). The chatbot receives only the customer's inbound reply and has no visibility into which campaign preceded it — breaking commerce autoreply context.

This RFC enriches the CAA inbound webhook payload (Services::Webhooks::CustomAgentAllocation, event custom_agent_allocation) with a single campaign_context envelope ({ trigger, broadcasts: [...] }) — one consistent shape regardless of case — so the chatbot can autoreply with awareness of what was sent, without a secondary lookup.

Case 1 — interactive-button reply (single). The inbound message's first button is type == 'BUTTON' and it has a reply pointer (customer.rb:60-63); the broadcast is resolved as MessageBroadcastInteractiveLogWorker does — reply_id → parent outbound Models::Message → raw_message['message_broadcast_id'] (message_broadcast_interactive_log_worker.rb:16-19). The envelope's trigger = interactive_button, broadcasts = that one broadcast.

Case 2 — non-interactive reply (all). All campaign outbound messages in the room within the 30-day window (is_campaign: true, partition-pruned), each resolved to broadcast → template, are returned. trigger = room_broadcasts, broadcasts = all of them (newest-first, cap 20).

Consistency (the format change): today the CAA payload already carries a thin last_campaign hash (custom_agent_allocation.rb:72-94), room-origin only, last_*-prefixed, and it never even sets last_sent_at (:78). rev4 introduces one canonical envelope whose per-broadcast schema is a superset of last_campaign (same identity fields, consistently named, plus the PRD content fields). last_campaign is preserved but deprecated (see Decision 6 mapping) so existing consumers keep working while the chatbot reads only campaign_context.

Success Criteria

  • Case 1: interactive-button reply → campaign_context.trigger == "interactive_button" and campaign_context.broadcasts has exactly one entry (that broadcast, full schema incl. replied_button). Verifiable by rspec.
  • Case 2: non-interactive reply with ≥1 prior campaign in 30 days → campaign_context.trigger == "room_broadcasts" and broadcasts has all in-window campaigns (newest-first, cap 20). Verifiable by rspec.
  • Consistent shape: every broadcasts[] entry has the same field set in both cases; the envelope is one object (not two loose keys). Verifiable by schema assertion.
  • UC4 (backdated): resolved by send-time window (both cases). Verifiable.
  • No context / flag OFF: flag ON but not a reply / no in-window campaign → campaign_context: { "trigger": null, "broadcasts": [] }; flag OFF → campaign_context absent (byte-compatible with today). last_campaign present and unchanged in all cases. Verifiable.
  • No schema migration; no extra external API call; every messages read partition-pruned; case-2 broadcast→template resolution is batched (no N+1). Verifiable by EXPLAIN + rspec.

Out of Scope

  1. Chatbot-side consumption (CAA-S05 / QC-23109) — Chatbot/Automation squad.
  2. Removing / re-populating last_campaign — kept unchanged for backward-compat and marked deprecated; actual removal is a separate follow-up once no consumer reads it (Open Q#10).
  3. Retroactive registration — new sends only; no backfill.
  4. The message_interaction / receive_message_from_customer webhook — unchanged.
  5. Modifying the interactive-log / reply-recording writes — reused read-only.
  6. BUTTON_REPLY / order-message BUTTON — fall to case 2 (Open Q#3).

Assumptions

  1. CAA = the custom_agent_allocation webhook (custom_agent_allocation.rb:17-20).
  2. The interactive-broadcast-reply signal already exists (customer.rb:60-63 + message_broadcast_interactive_log_worker.rb:10-33). We reuse it read-only.
  3. Ordering: CAA delivery is enqueued in Creates::Customer#call (:40-43); MarkRepliedWorker and MessageBroadcastInteractiveLogWorker are enqueued async (:60-63, :295) — so CAA enrichment resolves from data already on the inbound message (buttons, reply_id), not from either worker having run.
  4. message_category exists on the template (message_template.rb:43-58); a unified message_type is composed from template flags.

Dependencies

DependencyTypeOwnerStatusBlocking?
CAA webhook contract accepts the additive campaign_context envelopeContractChat-1 / Inbox backendadditive JSON; no schema changeNO
Chatbot handler reads campaign_context (CAA-S05 / QC-23109)FeatureChatbot / Automation squadto buildNO for BE delivery
Feature flag register_campaign_type_to_caa seeded (default OFF)ConfigInfra/Platform via Services::Preferenceto seedYES
Confirm last_campaign deprecation window (consumers migrate to campaign_context)CoordinationChat-1 + ChatbotOpen Q#10Before removal (not this RFC)

PRD-to-Schema Derivation (backend-specific — required)

No new persisted schema. Every field is derived at read time from existing tables (messages, message_broadcasts, message_templates) + reuse of the interactive-log linkage.

PRD-described ruleSourced from (existing)Exposed via (campaign_context…)Derived / enforced whereSource (PRD §)
Case 1: reply to an interactive (quick-reply) broadcastinbound messages.buttons[0]['type']=='BUTTON' + messages.reply_id; parent messages.raw_message['message_broadcast_id'].trigger="interactive_button" + 1-element .broadcastsWhatsapp::Services::InteractiveBroadcastReply (new)FR-1, CAA-S04
Case 2: all campaign broadcasts in room (window)messages.is_campaign=true + messages.room_id + messages.created_at window.trigger="room_broadcasts" + N-element .broadcastsRepositories::Rooms::CampaignContexts (new)FR-1, CAA-S01
30-day backdated window (UC4)messages.created_at vs reply created_at.broadcastswindow (reply.created_at - 30.days)..reply.created_atFR-2, CAA-S02
Broadcast identitymessage_broadcasts.id, .name.broadcasts[].broadcast_id, .broadcast_namebuilderFR-1 (consistency)
Template identitymessage_templates.id, .name.broadcasts[].template_id, .template_namebuilderFR-1 (consistency)
message_typemessage_templates.type,.message_type,.is_marketing_opt_in,.is_request_phone_number.broadcasts[].message_typeCampaignMessageTypeResolver (new)FR-1
message_categorymessage_templates.category.broadcasts[].message_categoryresolverFR-1
campaign_sent_atlinked campaign messages.created_at.broadcasts[].campaign_sent_atbuilderFR-1/FR-2
message_bodymessages.text fallback message_templates.body.broadcasts[].message_bodybuilderFR-1, CAA-S03
message_content_textmessage_templates.{header,body,footer} + header[:format] + raw_message['carousel'].broadcasts[].message_content_textCampaignContentSerializer (new)FR-1, CAA-S03
Tapped button (case 1)inbound messages.buttons[0]['text'] (= interactive log value).broadcasts[].replied_buttonInteractiveBroadcastReplyCAA-S04
Chatbot stops when room assigned/resolved → no enrichmentactive Models::AgentParticipant guard (custom_agent_allocation.rb:19)CAA does not fireexisting guard — unchangedUC3, CAA-S04
Feature-gatedServices::Preference.new.enabled?(:register_campaign_type_to_caa)guard in CustomAgentAllocationrollout

Detail 1.A — PRD Traceability Matrix

Forward:

PRD requirementService / artifactRFC section
FR-1 enrich CAA webhook (two cases, one envelope)CustomAgentAllocation (extend) + CampaignContexts + InteractiveBroadcastReply (new)§2.1, §2.2, §2.4
FR-2 backdated (UC4), 30-day windowCampaignContexts windowDecision 2
CAA-S03 content serializationCampaignContentSerializerDecision 5
CAA-S04 interactive discriminationInteractiveBroadcastReply + resolverDecision 3
CAA-S05 chatbot consumptionChatbot squad (external)§2.D row 7 (out of scope)
Consistent payload formatsingle campaign_context envelopeDecision 6, §2.4

Reverse:

New artifactPRD need
Whatsapp::Services::InteractiveBroadcastReplycase-1 detection + single-broadcast resolution + replied_button
Repositories::Rooms::CampaignContextsorchestrate cases 1/2; case-2 batched list-all
Whatsapp::Services::CampaignMessageTypeResolvermessage_type + message_category
Whatsapp::Services::CampaignContentSerializermessage_content_text
Entities/Builders::Webhooks::CampaignContextthe per-broadcast object (10 fields, consistent)
campaign_context envelope in CustomAgentAllocation#build_messageconsistent payload enrichment
flag register_campaign_type_to_caastaged rollout

UI / Consumer Surface Coverage

PRD-named surfaceConsumerReadsWritesStatus surface
CAA webhook (custom_agent_allocation)Chatbot/automation (external HTTP)receives enriched payload (push)campaign_context envelope
Inbox roomWeb/Mobile FEexisting thread API (no change)unchanged

No new HTTP endpoint — only the outbound CAA webhook (push): n/a — covered by webhook push.

Role Coverage

PRD roleAuthorizationEndpointsCross-tenant?Audit trail
Chatbot / automation (webhook consumer)org-registered URL + org setting custom_agent_allocationreceives CAA POST onlyNo — payload scoped to organization_idServices::Webhooks::EventLog
End customer (reply sender)n/a — inbound reply triggers CAANomessage row
Agent (human)if assigned → CAA does not fireNoparticipant records

PRD Section Coverage

PRD sectionTitleWhere covered
Background / Root CauseProblem§1
CAA Architecture UC1–UC4flows§1, §2.2, §2.D
UC4 Timestamp BehaviorbackdatedDecision 2
FR-1/2/3requirements§1 PRD-to-Schema, §2
Content-to-text rulesserializationDecision 5
Message Types to Registereligibility + interactive discriminationDecision 3
User Stories CAA-S01…S05stories§1 Detail 1.C
Dependencies / Open Questions / Out of Scope§1, §5

Detail 1.B — Key Decisions Summary

#DecisionChosen option§2 block
1Storage of derived fieldsDerive at read time from MessageTemplate — no column/migrationDecision 1
2Windows & queriesCase-1 single via reply_id → parent → message_broadcast_id; case-2 all via room window; both partition-prunedDecision 2
3Case discriminatorReuse the interactive-log condition (buttons[0].type=='BUTTON' + resolvable broadcast parent) → case 1; else case 2Decision 3
4Trigger surface & timingEnrich synchronously in CustomAgentAllocation#build_message, resolving from the inbound messageDecision 4
5Content-to-text serializationNew stateless CampaignContentSerializerDecision 5
6Consistent payload formatSingle campaign_context envelope ({ trigger, broadcasts:[…] }), one canonical per-broadcast schema (superset of last_campaign); last_campaign preserved + deprecatedDecision 6
7Case-2 multiplicity & costAll in-window campaigns, batched broadcast→template resolution, newest-first, cap 20 (no N+1)Decision 7
8Reuse vs new webhook eventExtend the existing custom_agent_allocation event (no new endpoint/event)Decision 8
9Cachingnone — 1–2 pruned reads on an async path; cache adds invalidation risk for negligible gainMinimum-coverage decisions
10Third-party integrationnone added — reuse the existing CaaWorker HTTP senderMinimum-coverage decisions
11Consistency modelRead-time derivation → strongly consistent with template state (only replica lag)Minimum-coverage decisions
12Multi-tenancy isolationEnforced in the new reads (organization_id + room_id) + org-scoped URL lookupMinimum-coverage decisions
13Rollout gateFeature flag register_campaign_type_to_caa (default OFF, org-scoped)Minimum-coverage decisions

Detail 1.C — Per-Story Change Map

Story #TitleLayer scopeBE changesAcceptance criteria (verifiable)RFC anchors
CAA-S01 (QC-23105)Webhook enriched (UC2)BE-onlyCampaignContexts repo (both cases); CustomAgentAllocation merges the campaign_context envelope; entity/builderrspec: interactive reply → trigger:interactive_button, 1 broadcast; non-interactive w/ prior campaigns → trigger:room_broadcasts, all broadcasts; no prior → {trigger:null,broadcasts:[]}§2.1; §2.2; §2.4; §4.C ch.5,6
CAA-S02 (QC-23106)Backdated (UC4)BE-onlywindow anchored on reply created_at (both cases)rspec: campaign created_at < room.created_at → resolved; exactly 30d → included; >30d → excluded§2.2; Decision 2; §4.C ch.5
CAA-S03 (QC-23107)Content serializedBE-onlyCampaignContentSerializer (body/media/buttons-ignored/carousel/missing→"")rspec truth-tableDecision 5; §4.C ch.3
CAA-S04 (QC-23108)Interactive discrimination + eligibilityBE-onlyInteractiveBroadcastReply (case-1 detect + replied_button); resolver; case-2 is_campaign+status filterrspec: interactive-button reply → single button broadcast; non-interactive → room set; follow_up/authentication excludedDecision 3; §2.1; §4.C ch.2
CAA-S05 (QC-23109)Chatbot consumes contextCross-squadn/a — Chatbot/Automation squad; BE guarantees the consistent campaign_context envelope + backward compatrspec (BE): flag OFF / no context → byte-compatible w/ today (campaign_context absent / empty envelope)§2.4; §4.C ch.6

2. Technical Design

Infrastructure Topology

No new infrastructure. Indexed DB reads on the Postgres replica + additive webhook fields.

Deployment topology

flowchart TB
meta([Meta WhatsApp Cloud]) -->|inbound reply webhook| lb[Load Balancer / API Gateway]
lb -->|HTTP| hub_svc["hub_service pods xN (Grape /webhooks)"]
hub_svc -->|produce| kafka[["Kafka (Karafka) inbound topic"]]
kafka -->|consume| hub_worker["hub_worker pods xM (KafkaConsumers + Sidekiq)"]
hub_worker -->|read/write| db_primary[(Postgres primary — chat)]
hub_worker -->|read-only linkage/campaign lookup| db_replica[(Postgres replica)]
hub_worker -->|GET org + webhook urls| redis[(Redis R/W)]
hub_worker -->|enqueue :custom_agent_allocation| sidekiq[["Sidekiq queue"]]
sidekiq -->|CaaWorker| hub_worker2["hub_worker Sidekiq pods"]
hub_worker2 -->|HTTPS POST (Pigeon/Typhoeus)| chatbot(["Chatbot / CAA consumer (external)"])

Per-service responsibility

flowchart LR
subgraph hub_core["hub_core (this RFC)"]
mc["Messages::Creates::Customer (fan-out on inbound)"]
caa["CustomAgentAllocation (enrich + deliver — EXTENDED)"]
repo["Rooms::CampaignContexts (NEW — cases 1/2 → envelope)"]
ibr["InteractiveBroadcastReply (NEW — case-1 detect)"]
res["CampaignMessageTypeResolver (NEW)"]
ser["CampaignContentSerializer (NEW)"]
worker["Webhooks::CaaWorker (existing — HTTP send)"]
end
mc -->|"deliver_caa_webhook! (no active agent)"| caa
caa -->|"read (flag-gated)"| repo
repo --> ibr
repo --> res
repo --> ser
caa -->|"enqueue :custom_agent_allocation"| worker
worker -->|HTTPS| chatbot(["Chatbot / CAA consumer (external)"])
repo -->|DB read (replica)| db[(messages, message_broadcasts, message_templates)]

Technical Decisions (ADR-format)


Decision 1: Derive fields at read time — no new column

Context The 5 PRD content fields don't exist on the message row (only is_campaign + raw_message.message_broadcast_id); a unified message_type/message_category exists only on the template. messages is range-partitioned by created_at and is a hot write table. PRD scope is "new sends only" (no historical backfill required).

Options considered

  • Option A — derive at read time from raw_message.message_broadcast_id → MessageBroadcast → message_template.
    • Pros: zero migration; no write-path change; always consistent with the template's current category/flags; the provenance link already exists (message_broadcast.rb:12 belongs_to :message_template); mirrors the existing get_last_campaign (custom_agent_allocation.rb:83-89).
    • Cons: a small read at CAA time (bounded — Decisions 2/7).
  • Option B — denormalize columns onto messages + backfill.
    • Pros: single-row read, no join.
    • Cons: a migration + backfill on a partitioned hot table (the single riskiest change here); a write-path change in every campaign-insert path; unjustified because PRD needs no backfill.

Decision Option A — derive at read time.

Rationale The risk/benefit is lopsided: Option B pays a partitioned-table migration + backfill for a feature the PRD explicitly says needs none, while Option A reuses an already-present link and the same resolution the current CAA code already performs. Read-time derivation also means the emitted message_category/type always reflect the template's live state (no denormalization drift).

Consequences Case 1: 1 parent read + 1 template resolution. Case 2: 1 window read + 1 batched resolution (Decision 7). Correctness depends on template flags being set by the create/broadcast flows (verified). Reversibility delete the code; no data to migrate — fully reversible.


Decision 2: Lookup windows & queries — reuse the proven shapes

Context Two lookups are needed: (case 1) the single broadcast an interactive button belongs to; (case 2) all campaign broadcasts in the room within 30 days. messages is partitioned, so any query must carry a created_at range for pruning (AGENTS.md). We must also decide the reference time that makes UC4 backdated sends resolvable.

Options considered

  • Option A — reuse the existing production query shapes, anchored on the inbound reply's created_at: case 1 = reply_id → parent → raw_message['message_broadcast_id'] (message_broadcast_interactive_log_worker.rb:16-19), parent lookup pruned to created_at:(reply-30d..reply); case 2 = where(room_id:, is_campaign:true, status:[...]).where(created_at:(reply-30d..reply)).order(created_at: :desc) (list-all shape, conversation_sessions.rb:15; window mark_replied_worker.rb:122).
    • Pros: identical to code already in production (low risk); partition-pruned; anchoring on the reply's created_at (= "now") makes it always ≥ any prior campaign's backdated created_at, so a single window captures UC2 and UC4 identically; case-1 resolution matches how the reply is attributed to a broadcast, so CAA context and reply attribution agree by construction.
    • Cons: none material (inclusive boundary handled with ..).
  • Option B — anchor on room.created_at (or use the worker's unpruned find_by(id:)).
    • Cons: room.created_at mis-sizes the window for UC2 (campaign into an older open room); an unpruned find_by(id:) scans all partitions. Rejected.

Decision Option A.

Rationale Reusing shapes already proven in mark_replied_worker/conversation_sessions minimises risk and keeps CAA consistent with the platform's own reply attribution; anchoring on the reply time is the only anchor that captures backdated UC4 sends with one window. The one improvement over the existing worker is pruning the case-1 parent lookup (adding a created_at range).

Consequences Inclusive 30-day boundary; UC4 captured; reads only (no write to the reply-recording state). Reversibility the window is a single expression (env-tunable, mark_replied_worker.rb:112).


Decision 3: Case discriminator — reuse the interactive-log condition

Context The bifurcation hinges on "is this inbound a reply to an interactive (quick-reply) broadcast?". The platform already answers this when it decides to write a Models::MessageBroadcastInteractiveLog. We must pick how to detect case 1.

Options considered

  • Option A — reuse the exact interactive-log condition in a new Whatsapp::Services::InteractiveBroadcastReply: message.buttons&.first&.dig('type') == 'BUTTON' (customer.rb:60) + a resolvable reply_id → parent → raw_message['message_broadcast_id'] (message_broadcast_interactive_log_worker.rb:16-19); also returns replied_button = message.buttons.first['text'] (= interactive-log value).
    • Pros: the canonical, already-trusted signal — no new heuristic; guarantees the case-1 broadcast is exactly the one the interactive log attributes; unit-testable in isolation.
    • Cons: none — it's the same predicate the platform uses (just extracted; today it's inlined).
  • Option B — scan the template's buttons for a QUICK_REPLY entry (rev2's approach).
    • Cons: a template-shape check, not a this-reply-tapped-a-button check — it would classify a plain text reply to a quick-reply template as case 1, which is wrong; also risks conflating the template's uppercase 'QUICK_REPLY' with the lowercase 'quick_reply' send-param sub_type (send.rb:383).
  • Option C — a brand-new heuristic.
    • Cons: divergence from the platform's own attribution; two sources of truth. Rejected.

Decision Option A.

Rationale Case 1 must mean "the customer tapped a quick-reply button of a broadcast" — which is precisely the interactive-log condition, not merely "the template had quick-reply buttons". Reusing that condition keeps CAA's case-1 broadcast identical to what the interactive log records, and avoids the QUICK_REPLY-vs-quick_reply trap.

Consequences ⚠️ Edge cases sharing type == 'BUTTON': order messages (webhook_receiver.rb:304) have no broadcast parent → fall to case 2; interactive-list replies use 'BUTTON_REPLY' (:270-271) which doesn't match == 'BUTTON' → case 2. Documented (Open Q#3). Reversibility a single service; the condition is one method.


Decision 4: Trigger surface & timing — synchronous enrichment in the CAA build

Context On an inbound message, Creates::Customer#call enqueues MarkRepliedWorker and MessageBroadcastInteractiveLogWorker (async, :60-63/:295) and delivers the CAA webhook (:40-43). We must choose where the enrichment runs — and it must not race the async workers.

Options considered

  • Option A — synchronously in CustomAgentAllocation#build_message (where the existing get_last_campaign runs, :46), resolving the linkage from the inbound message itself (it already carries buttons and reply_id, set pre-create at transaction_customer_send_message.rb:302).
    • Pros: build_message is already the single place campaign context is assembled and is off the HTTP request path (Kafka consumer / inbound worker); does not depend on either async worker having run; keeps CaaWorker a pure sender.
    • Cons: adds 1–2 replica reads to the payload-build path (negligible vs the ES/Redis calls already there).
  • Option B — depend on the async workers having back-linked the message / written the interactive log.
    • Cons: unsafe — CAA delivery is enqueued (:40-43) before those workers run; the data may not exist yet. Rejected.
  • Option C — enrich inside CaaWorker (the HTTP sender).
    • Cons: repeats the DB read on every retry; splits payload construction across two places. Rejected.

Decision Option A.

Rationale Correctness forces it: because CAA fires before the async workers, the enrichment must be self-contained and resolve from the inbound row — which build_message can do synchronously. It also keeps one source of truth for the payload and preserves CaaWorker as a retry-safe pure sender.

Consequences 1 (case 1) or 2 (case 2) replica reads per CAA fire; negligible. Reversibility movable into a worker later — the repository is transport-agnostic.


Decision 5: Content-to-text serialization — new stateless serializer

Context message_content_text must be a self-contained plain-text rendering of the campaign message. A grep confirmed no existing serializer for template/campaign content (to_text/preview/[template contains/[Products: — none in app/). The closest structure-walkers are extract_content (create.rb:154-164) and the header-format branching in user_send_hsm.rb:155-177.

Options considered

  • Option A — a new stateless Whatsapp::Services::CampaignContentSerializer, modeled on those two.
    • Pros: single tested unit; matches the stateless-service pattern (enrich_carousel_cards.rb); no coupling to webhook code; reusable when the case-2 set widens.
    • Cons: a new class.
  • Option B — extend the room-list preview (Builders::RoomList::SetMessage).
    • Cons: that path builds the inbox room-list cache (different shape/purpose); overloading it risks regressions in the inbox list. Rejected.

Decision Option A. Rules: body as-is; header+footer → "{header}: {body} — {footer}" (else body); media (header[:format] ∈ {IMAGE,VIDEO,DOCUMENT}) → append "[template contains image media]"; buttons ignored; carousel → "[Products: 1. \"{name}\" — {price} — \"{description}\" / ...]"; missing field → "".

Rationale A dedicated pure function is trivially unit-testable against the PRD/CAA-S03 truth-table and keeps the (regression-sensitive) inbox room-list builder untouched. Media detection reuses the exact header[:format] predicate from user_send_hsm.rb:165; carousel reads raw_message['carousel'] as the display builder does (builders/message_list/message.rb:95-98).

Consequences Header/footer follows the CAA-S03 AC, which contradicts the PRD conversion table's "not available" note (Open Q#4). description is not stored on enriched cards → "". Reversibility pure function; localized.


Decision 6: Consistent payload format — a single campaign_context envelope

Context After adding campaign context, the CAA payload would carry two inconsistent representations: the legacy last_campaign (flat, last_*-prefixed, room-origin, last_sent_at never set) and a new per-broadcast set. The two cases (single vs array) also risk two shapes. The chatbot needs one predictable format.

Options considered

  • Option A — single campaign_context envelope { trigger, broadcasts: [ {…10 fields…} ] }; same element schema for both cases; last_campaign kept for backward-compat + deprecated.
    • Pros: one shape the chatbot always parses; case 1 = 1-element array, case 2 = N; per-broadcast schema is a superset of last_campaign (so consumers can migrate off it); additive.
    • Cons: two campaign-ish keys coexist during the deprecation window (last_campaign + campaign_context) until last_campaign is removed (Open Q#10).
  • Option B — two loose top-level keys (campaign_messages array + campaign_context_trigger string), as in rev3.
    • Cons: the discriminator floats separately from the data; still inconsistent with last_campaign; two shapes to document.
  • Option C — re-purpose last_campaign to carry the new fields.
    • Cons: breaks existing consumers relying on the current last_campaign shape/semantics; the last_* naming is inconsistent with the PRD field names; rejected.

Decision Option A.

Rationale One envelope gives the chatbot a single predictable shape to parse (case 1 = a 1-element array, case 2 = N) instead of branching on two loose keys; making each element a superset of last_campaign lets consumers migrate to one field and lets us retire the inconsistent last_* naming later — without breaking anyone now (B keeps the inconsistency; C breaks existing last_campaign consumers). Keeping the 5 PRD field names verbatim preserves the PRD contract while the identity fields adopt clean, consistent names.

Canonical per-broadcast schema (one object; consistent snake_case; PRD field names kept for the 5 content fields):

FieldTypeSourceNotes
broadcast_iduuidMessageBroadcast.idsupersedes last_campaign.last_campaign_id
broadcast_namestringMessageBroadcast.namesupersedes last_campaign_name
template_iduuidmessage_template.idsupersedes last_sent_template_id
template_namestringmessage_template.namesupersedes last_sent_template_name
message_typestring enumcomposedPRD field
message_categorystring enummessage_templates.categoryPRD field
campaign_sent_atstring (ISO-8601 UTC)messages.created_atPRD field; supersedes last_sent_at (which was never set)
message_bodystring (may be "")messages.text / template bodyPRD field
message_content_textstring (may be "")serializerPRD field
replied_buttonstring | nullinbound buttons[0]['text']case 1 only (the tapped button); null in case 2

last_campaigncampaign_context field mapping (for consumer migration):

Legacy last_campaignCanonical campaign_context.broadcasts[]
last_campaign_idbroadcast_id
last_campaign_namebroadcast_name
last_sent_template_idtemplate_id
last_sent_template_nametemplate_name
last_sent_at (never set)campaign_sent_at
+ message_type, message_category, message_body, message_content_text, replied_button

Consequences last_campaign remains byte-identical (existing consumers unaffected); it is marked deprecated and slated for removal once the chatbot reads campaign_context exclusively (Open Q#10 — not this RFC). The envelope is present whenever the flag is ON (empty { "trigger": null, "broadcasts": [] } when no context) so the consumer never branches on key presence; absent when the flag is OFF (byte-identical to today).

Reversibility Remove the merged key; additive, no data.


Decision 7: Case-2 multiplicity & cost — all, batched, capped

Context Case 2 returns every campaign broadcast in the room within 30 days; a room could hold many. Resolving each message's broadcast→template individually is an N+1 on the inbound hot path (the exact gap the rev1 review flagged), and an unbounded array risks payload/worker cost.

Options considered

  • Option A — return all in-window campaigns, batch-resolved, newest-first, capped 20. Collect the window results' message_broadcast_ids, then one Models::MessageBroadcast.where(id: ids).includes(:message_template).
    • Pros: satisfies "send all"; one batched query (Bullet-clean); the cap bounds pathological rooms; the status filter (created/sent/delivered/read) + is_campaign naturally exclude follow-up (is_campaign=false) and authentication (never in a room).
    • Cons: >20 campaigns in 30 days are truncated (logged + truncated:true metric — no silent cap).
  • Option B — per-message resolution (no batching). Cons: N+1 on the hot path; Bullet fails in tests.
  • Option C — most-recent only. Cons: contradicts "send all broadcast contexts in the room".

Decision Option A.

Rationale Batching is the specific fix for the flagged N+1 while still honouring "all"; the cap + truncation log bounds cost observably; the natural type exclusions come for free from is_campaign + status without a separate eligibility filter.

Consequences Rooms with >20 in-window campaigns drop the oldest (logged). Reversibility cap and order are constants.


Decision 8: Reuse the CAA event vs a new webhook event

Context The context could ride the existing custom_agent_allocation webhook or a brand-new event.

Options considered

  • Option A — extend the existing custom_agent_allocation payload (the envelope, Decision 6).
    • Pros: the chatbot already receives CAA on inbound; additive, no new subscription/URL; PRD wants the context "inline … no secondary lookup".
    • Cons: payload grows (bounded by Decision 7).
  • Option B — a new webhook event carrying campaign context.
    • Cons: new event registration + consumer URL + correlation/ordering with the inbound message; contradicts "no secondary lookup". Rejected.

Decision Option A — extend the CAA event (no new endpoint/event).

Rationale CAA is the inbound feed the chatbot consumes; an additive key is the minimal change that delivers the context inline. Consequences one webhook, additive. Reversibility remove the key.


Minimum-coverage decisions (rubric checklist)

Short decisions for the remaining standard concerns; each states the choice and why.

  • Caching — none (deliberate). The enrichment is 1 (case 1) or 2 (case 2) partition-pruned replica reads per CAA fire, on an already-async path; a cache would add invalidation risk (template/broadcast edits, new sends) for negligible latency saving. Chosen: no cache. Reversible: add a short-TTL Redis cache keyed by room_id + reference-minute later if profiling shows a hot spot.
  • Third-party integration — none added. No new external API call; the only outbound HTTP is the existing chatbot POST via Webhooks::CaaWorker (reused unchanged, with its retry/circuit-breaker). Chosen: reuse the existing sender; add nothing.
  • Consistency model — read-time / strongly consistent with template state. Because fields are derived at read time (Decision 1), the emitted message_type/message_category/content always reflect the template's current state; there is no denormalized copy to drift. The only staleness is replica lag on a just-sent campaign (accepted — §2.A / Open Q#8).
  • Multi-tenancy isolation — enforced in the new reads. Every query in RepliedBroadcastContext/ CampaignContexts includes organization_id (case 2 also room_id) per the AGENTS.md rule; the destination URL set comes from the org-scoped Services::Redis::Webhooks::GetUrlByEvent.new(@organization_id, …). Chosen enforcement point: the repository query + the existing per-org URL lookup (no cross-tenant path).
  • Rollout gate — feature flag register_campaign_type_to_caa, default OFF, org-scoped. Chosen over an always-on rollout so the change ships dark and enables per-org (internal → GA); kill-switch = flip OFF (campaign_context stops; last_campaign unaffected). Uses Services::Preference (the repo's standard flag mechanism). Reversible instantly.

Detail 2.0 — Repo Reading Guide

Repo Map (mermaid)

flowchart LR
subgraph inbound["hub_core inbound path"]
cust["repositories/messages/creates/customer.rb\n(:40-43 CAA · :60-63 interactive guard · :295 reply worker)"]
end
subgraph reuse["existing linkage (reused, not modified)"]
ilw["workers/message_broadcast_interactive_log_worker.rb\n(:16-19 reply_id→parent→broadcast_id)"]
mrw["workers/message_broadcasts/mark_replied_worker.rb\n(:122 window · :132 query)"]
end
subgraph caa["CAA webhook"]
svc["services/webhooks/custom_agent_allocation.rb (EXTEND)"]
end
subgraph newcode["NEW (this RFC)"]
repo["repositories/rooms/campaign_contexts.rb"]
ibr["apps/whatsapp/services/interactive_broadcast_reply.rb"]
res["apps/whatsapp/services/campaign_message_type_resolver.rb"]
ser["apps/whatsapp/services/campaign_content_serializer.rb"]
ent["entities/webhooks/campaign_context.rb (per-broadcast, 10 fields)"]
bld["builders/webhooks/campaign_context.rb"]
end
subgraph models["models"]
m_tmpl["message_template.rb (buttons, type, message_type, category, name)"]
m_bc["message_broadcast.rb (:12 belongs_to message_template; id, name)"]
m_msg["message.rb (is_campaign, raw_message, buttons, reply_id, created_at, text)"]
end
cust -->|deliver_caa_webhook!| svc
cust -.->|async| ilw
svc --> repo --> ibr
repo --> res
repo --> ser
repo --> bld --> ent
repo --> m_msg
repo --> m_bc --> m_tmpl
ibr --> m_msg

Existing Code Anchors

PathWhy the agent reads itWhat it teaches
hub_core/app/core/domains/services/webhooks/custom_agent_allocation.rbCAA service to extend; build_message (:44-47), get_last_campaign (:72-94), guards (:17-20), enqueue (:56)merge point for campaign_context; the legacy last_campaign shape it must stay consistent with
hub_core/app/core/domains/repositories/messages/creates/customer.rbinteractive-log guard (:60-63), CAA fan-out (:40-43), reply worker (:295)trigger surface + case-1 condition + CAA-vs-worker ordering (all async)
hub_core/app/core/workers/message_broadcast_interactive_log_worker.rbcase-1 resolution: reply_id → parent → raw_message['message_broadcast_id'] (:16-19); value = button textsingle-broadcast linkage + replied_button; parent find_by(id:) unpruned (:16)
hub_core/app/core/workers/message_broadcasts/mark_replied_worker.rbwindow shape: window (:122), query (:132)partition-pruned (reply-30d..reply) + is_campaign filter
hub_core/app/core/domains/services/metrics/room_participants/conversation_sessions.rblist-ALL campaigns in a room within a window (:15)case-2 array shape
hub_core/app/apps/wa_cloud/services/webhook_receiver.rbbutton shapes: quick-reply (:250-252 type:'BUTTON'), interactive-list (:270-271 'BUTTON_REPLY'), order (:304 'BUTTON'); context attach (:231)case-1 keys off 'BUTTON'; edge cases (Open Q#3)
hub_core/app/apps/wa_cloud/services/transaction_customer_send_message.rbfetch_reply_id (:302,:557-580) — context.id → reply_id (pruned)how reply_id is on the message before create
hub_core/app/core/domains/models/message_broadcast.rbbelongs_to :message_template (:12); id, namebroadcast identity fields + join
hub_core/app/core/domains/models/message_template.rbbuttons, type (:19-22), message_type (:24-26), category (:43-58), name, flags (:130-136)type/category composition + template identity
hub_core/app/core/domains/services/message/create_message_from_campaign.rboutbound raw_message ids (:209), is_campaign (:186)provenance ids on the outbound campaign message
hub_core/app/core/domains/builders/message_by_webhook.rb / entities/message_by_webhook.rbpayload builder + last_campaign slot (:62-64 / :29)builder/entity pattern; where the envelope merges
hub_core/app/core/domains/services/preference_v2.rbServices::Preference#enabled? (:20-76)feature-flag guard

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
CAA webhook custom_agent_allocation payloadextendedadditive campaign_context envelope; last_campaign preserved (deprecated)Chat-2 / Chat-1
last_campaign hash (get_last_campaign, custom_agent_allocation.rb:72-94)reused (unchanged) + deprecatedkept byte-identical for existing consumers; superseded by campaign_context (Decision 6)Chat-2 / Chat-1
Interactive-log condition + resolutionreused (pattern)extracted into InteractiveBroadcastReply; worker/model untouchedChat-2
Reply-window / list-all queriesreused (pattern)mirror the shapesChat-2
messages/message_broadcasts/message_templatesreused (read-only)read-time derivation, no DDLChat-2
Creates::Customer fan-out & CaaWorkerreusedno changeChat-2
Services::Preference flagnew-with-justificationregister_campaign_type_to_caa (default OFF)Infra/Platform

Patterns to Follow

ConcernPatternReference fileDeviation
Repository / DB accessAbstractRepository, Dry::Monads[:result], success/failure, switch_replica_dbabstract_repository.rb:3,23,28-29,59None
EntityEntities::AbstractEntity < Dry::Structentities/abstract_entity.rb:3; entities/message_by_webhook.rbNone
BuilderBuilders::AbstractBuilder; returns plain entitybuilders/abstract_builder.rb:3,9None
Batched association load (avoid N+1).where(id: ids).includes(:assoc)Bullet enforced in test env (AGENTS.md)None
Partition-pruned message query.where(room_id:, is_campaign:true, ...).where(created_at: range)mark_replied_worker.rb:132; conversation_sessions.rb:15None
Feature flagServices::Preference.new.enabled?(:flag, organization_id: id)preference_v2.rb:20-76None
Webhook payload mergeBuilders::MessageByWebhook.build.attributes.merge(...)custom_agent_allocation.rb:44-47extend with campaign_context

Reading Order for the Agent

  1. hub_core/AGENTS.md — layers, feature-flag API, partition-pruning, Bullet/N+1, test prereqs.
  2. custom_agent_allocation.rb — extension point + the last_campaign shape to stay consistent with.
  3. repositories/messages/creates/customer.rb:39-43,60-63,279-295 — trigger + case-1 condition + ordering.
  4. message_broadcast_interactive_log_worker.rb:10-33 — case-1 resolution + value/replied_button.
  5. mark_replied_worker.rb:115-148 + conversation_sessions.rb:15 — window + list-all.
  6. wa_cloud/services/webhook_receiver.rb:231,250-252,270-271,304 — button shapes + edge cases.
  7. models/message_broadcast.rb + message_template.rb — identity fields + type/category.
  8. create.rb:154-164 + user_send_hsm.rb:155-177 — content structure + media branching.

Source Verification (anti-hallucination)

Anchor / claimVerified byEvidence
CAA fires only when no active agent + not comment roomreadcustom_agent_allocation.rb:17 skip Models::CommentServiceRoom; :19 active Models::AgentParticipant exists?; :20 guard
Legacy last_campaign shape (to stay consistent with)read:72-79 { last_campaign_id:, last_campaign_name:, last_sent_template_id:, last_sent_template_name:, last_sent_at: } (last_sent_at: nil, never reassigned); :81 # TODO: need new column broadcast_id; :82 if @room.broadcast_id.present?
Interactive-log CREATE guard (case-1 discriminator)readcustomer.rb:60-63 `if message.buttons&.first&.dig('type') == 'BUTTON' && (message.reply_id.present?
Case-1 resolution + replied_buttonreadmessage_broadcast_interactive_log_worker.rb:16 parent find_by(id: reply_id); :17 return if ...raw_message['message_broadcast_id'].nil?; :19 broadcast_id = parent.raw_message['message_broadcast_id']; value = button_value (the tapped text)
CAA/workers ordering (all async)readcustomer.rb:39/:40-43/:60-63/:295
Reply-window shapereadmark_replied_worker.rb:115/:122/:132
List-ALL campaigns in room (case 2)readconversation_sessions.rb:15 Models::Message.where(room_id:..., is_campaign: true, created_at: time_delta..Time.zone.now)
Inbound button shapes + contextreadwebhook_receiver.rb:250-252 [{text:, type:'BUTTON'}]; :270-271 'BUTTON_REPLY'; :304 order 'BUTTON'; :231 attr[:context]=message['context']
reply_id set from context before createreadtransaction_customer_send_message.rb:302,:557-580 (pruned + widening fallback)
Broadcast identity + template joinreadmessage_broadcast.rb:12 belongs_to :message_template; MessageBroadcast.name; create_message_from_campaign.rb:209 { message_broadcast_id: }
Template flags + namereadmessage_template.rb:19-22/:24-26/:43-58/:130-136; .name
No existing content-to-text serializergrep (negative)none in app/
Feature-flag APIread + AGENTS.mdpreference_v2.rb:20-76
Repo/entity/builder base classesreadabstract_repository.rb:3,59; entities/abstract_entity.rb:3; builders/abstract_builder.rb:3,9

Detail 2.1 — Architecture (mermaid)

Branch & skip flow — two cases → one envelope

flowchart TD
A([Inbound reply → CAA#deliver]) --> B{room present & not comment room\n& no active agent?}
B -- no --> Z([CAA does not fire — bot stopped])
B -- yes --> C{flag register_campaign_type_to_caa ON?}
C -- OFF --> P0[today's payload\n(last_campaign only; campaign_context ABSENT)]
C -- ON --> D{InteractiveBroadcastReply:\nbuttons[0]=='BUTTON' & reply_id→parent→broadcast_id?}
D -- yes (CASE 1) --> E1[resolve that ONE broadcast→template\n+ replied_button]
E1 --> P1["campaign_context = { trigger:'interactive_button', broadcasts:[1] }"]
D -- no --> G{CASE 2: any campaign outbound in room within 30d?}
G -- none --> PE["campaign_context = { trigger:null, broadcasts:[] }"]
G -- some --> H[batched resolve broadcasts→templates\n(newest-first, cap 20)]
H --> P2["campaign_context = { trigger:'room_broadcasts', broadcasts:[N] }"]
P0 --> K([log_webhook + enqueue CaaWorker])
P1 --> K
PE --> K
P2 --> K

Data model (mermaid erDiagram) — read-only; no new tables

erDiagram
ROOMS ||--o{ MESSAGES : contains
MESSAGE_BROADCASTS ||--o{ MESSAGES : "produces (via raw_message)"
MESSAGE_TEMPLATES ||--o{ MESSAGE_BROADCASTS : "used by"
MESSAGES { uuid id PK
uuid room_id FK
boolean is_campaign
uuid reply_id
jsonb raw_message
hstore_array buttons
string text
string sender_type
datetime created_at }
MESSAGE_BROADCASTS { uuid id PK
string name
uuid message_template_id FK }
MESSAGE_TEMPLATES { uuid id PK
string name
string type
string message_type
string category
hstore_array buttons
hstore header
string body
string footer }

Detail 2.2 — Sequence Diagrams

Case 1 — interactive-button reply (single)

sequenceDiagram
actor Cust as Customer
participant CUST as Messages::Creates::Customer
participant CAA as CustomAgentAllocation
participant IBR as InteractiveBroadcastReply
participant Repo as Rooms::CampaignContexts
participant DB_R as Postgres replica
Cust->>CUST: taps a quick-reply button (buttons[0].type='BUTTON', reply_id set)
CUST->>CAA: :40-43 deliver_caa_webhook! [no active agent]
CAA->>Repo: call(message, room) [flag ON]
Repo->>IBR: resolve(message)
IBR->>DB_R: parent = Message.find(reply_id, created_at:(t-30d..t))
DB_R-->>IBR: parent.raw_message['message_broadcast_id']
IBR-->>Repo: { broadcast_id, replied_button: buttons[0].text } (case 1)
Repo->>DB_R: MessageBroadcast(id,name) → message_template(id,name,buttons,category,type)
Repo-->>CAA: Success(campaign_context{ trigger:'interactive_button', broadcasts:[{10 fields}] })
CAA->>CAA: merge campaign_context (envelope)
Note over CAA: last_campaign left unchanged

Case 2 — non-interactive reply, all room broadcasts (array)

sequenceDiagram
participant CAA as CustomAgentAllocation
participant IBR as InteractiveBroadcastReply
participant Repo as Rooms::CampaignContexts
participant DB_R as Postgres replica
CAA->>Repo: call(message, room) [flag ON]
Repo->>IBR: resolve(message)
IBR-->>Repo: nil (not an interactive-button reply)
Repo->>DB_R: messages where room_id, is_campaign, created_at∈(t-30d..t) desc [pruned]
DB_R-->>Repo: [msg1, msg2, ...]
Repo->>DB_R: MessageBroadcast.where(id: ids).includes(:message_template) [BATCHED — no N+1]
Repo-->>CAA: Success(campaign_context{ trigger:'room_broadcasts', broadcasts:[{...}, {...}] cap 20 })

UC4 (backdated) — campaign created_at (send time) < room.created_at, still inside the reply-anchored window; applies to both cases.

Skip / no-op — flag OFF → campaign_context absent (byte-identical to today). Flag ON + not a reply / no in-window campaign / resolve error → campaign_context = { trigger:null, broadcasts:[] }; errors rescued so CAA always delivers.

Detail 2.3 — Database Model (DDL)

N/A — no schema change. Read-time derivation. Two partition-pruned reads:

# Case 1 — the interactive button's broadcast (parent lookup pruned)
parent = Models::Message.where(id: message.reply_id, organization_id: room.organization_id)
.where(created_at: (reference_at - 30.days)..reference_at).take
broadcast_id = parent&.raw_message&.dig('message_broadcast_id')
replied_button = message.buttons.first['text']

# Case 2 — all campaign broadcasts in room, then ONE batched resolution
msgs = Models::Message
.where(organization_id: room.organization_id, room_id: room.id, is_campaign: true)
.where(status: %w[created sent delivered read])
.where(created_at: (reference_at - 30.days)..reference_at)
.order(created_at: :desc).limit(20)
broadcasts = Models::MessageBroadcast
.where(id: msgs.map { |m| m.raw_message['message_broadcast_id'] }.compact.uniq)
.includes(:message_template) # batched — no N+1

EXPLAIN (ANALYZE, BUFFERS) must confirm partition pruning + room_id on the case-2 read (Open Q#2). No new table/column/index.

  • Per-status lifecycle: no status enum introduced.
  • PII: message_body/message_content_text = campaign copy already in messages; delivered only to the org's own registered CAA URL (tenant-scoped).

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

N/A — no inbound HTTP API added.

Outbound webhook (we POST to the chatbot) — the consistent contract

EventDirectionMethodAuthN/AuthZConsumerPayload changeIdempotencyVersioning
custom_agent_allocation (CAA)outbound (hub_core → chatbot)POST (Webhooks::CaaWorker)org-registered URL + org setting enabledChatbot/automationadditive: single campaign_context envelope; last_campaign preserved (deprecated)consumer-side; same body across retriesadditive — no version bump

campaign_context envelope:

KeyTypePresenceNotes
campaign_contextobjectpresent whenever flag ON; absent when flag OFF{ trigger, broadcasts }
campaign_context.triggerstring enum | null"interactive_button" (case 1) | "room_broadcasts" (case 2) | null (no context)
campaign_context.broadcastsarray of object[] when no context; 1 (case 1); ≤20 newest-first (case 2)element schema below — same in both cases

campaign_context.broadcasts[] element (10 fields, one canonical schema):

FieldTypeNullabilityNotes
broadcast_iduuid stringnon-nullMessageBroadcast.id
broadcast_namestringmay be nullMessageBroadcast.name
template_iduuid stringmay be nullmessage_template.id
template_namestringmay be nullmessage_template.name
message_typestring enumnon-nullcampaign|follow_up|marketing_opt_in|phone_number_request|carousel (composed)
message_categorystring enumnon-nullutility|marketing
campaign_sent_atstring (ISO-8601 UTC)non-nulllinked campaign messages.created_at
message_bodystringmay be ""rendered body / template body
message_content_textstringmay be ""serialized content (Decision 5)
replied_buttonstringnull in case 2case 1: the tapped button text (interactive-log value)

last_campaign (legacy, unchanged, DEPRECATED): still emitted with its current shape ({ last_campaign_id, last_campaign_name, last_sent_template_id, last_sent_template_name, last_sent_at }, room-origin). Consumers should migrate to campaign_context per the mapping in Decision 6; removal is tracked in Open Q#10 (not this RFC).

Enriched payload example (case 1):

{
"id": "…", "type": "text", "room_id": "…", "is_campaign": false,
"text": "Yes, track my order",
"last_campaign": { "last_campaign_id": "…", "last_campaign_name": "…", "last_sent_template_id": "…", "last_sent_template_name": "…", "last_sent_at": null },
"campaign_context": {
"trigger": "interactive_button",
"broadcasts": [
{
"broadcast_id": "bc-uuid", "broadcast_name": "Delivery Update June",
"template_id": "tpl-uuid", "template_name": "delivery_update_v2",
"message_type": "campaign", "message_category": "utility",
"campaign_sent_at": "2026-06-01T09:00:00Z",
"message_body": "Your order #123 shipped — reply to track.",
"message_content_text": "Order update: Your order #123 shipped — reply to track.",
"replied_button": "Track my order"
}
]
},
"organization_id": "…"
}

Case 2 — identical shape, trigger: "room_broadcasts", N broadcasts (newest-first), each replied_button: null. No contextcampaign_context: { "trigger": null, "broadcasts": [] }. Flag OFFcampaign_context absent (byte-identical to today).

Detail 2.A — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotencyConsistencyStale read
Enrichment (this RFC)None — read-onlyresolve/serialize error → campaign_context: {trigger:null,broadcasts:[]} + log; CAA still deliversper-delivery uniq_id (custom_agent_allocation.rb:54)read-time derivation → consistent with template statereplica read; a just-sent campaign may lag replication (accepted — §5)

The interactive-log / reply-recording writes are not touched.

Detail 2.B — Concurrency Collision Map

ResourceWritersCollisionResolutionOn conflict
CAA payload buildrapid successive replies in same roomeach builds its own payloadnone needed — read-only; per-delivery uniq_idindependent, idempotent on consumer
messages read vs broadcast insertenrichment read vs campaign writereplica lag may omit a very fresh campaignreplica snapshotcaptured on next reply

Detail 2.C — Async Job / Event Consumer Spec

Job/ConsumerTriggerInputRetryDLQConcurrencyIdempotencyTimeoutPoison
Webhooks::CaaWorker (existing, unchanged)perform_async from CustomAgentAllocation(event, uniq_id, url, headers, data.as_json) incl. campaign_contextretry:3 + backoff (caa_worker.rb:6-16)Sidekiq dead queuequeue :custom_agent_allocationconsumer-side (same body)Pigeon request_timeoutauto-disable org CAA on 404/error
Repositories::Rooms::CampaignContexts (NEW — synchronous, not a job)in-process from build_message(message, room)none; error → Success(campaign_context envelope, empty)n/ainherits CAA pathn/a (read)inherits callerrescue + CustomLogFormat.error
MessageBroadcastInteractiveLogWorker / MarkRepliedWorker (existing — unchanged; linkage reused)perform_async from customer.rb:60-63/:295existingexistingexistingexistingexistingexistingexisting

Detail 2.D — Responsibility Boundary Matrix

StepOwnerInbound triggerOutbound effectFailure handlerPRD anchor
1. Receive inbound reply, create messageChat-1 / channelMeta webhook → Kafkamessage row; fan-outinbound retry workerUC2/UC4
2. Record interactive-log / reply (existing)Chat-1 / core (workers)customer.rb:60-63/:295 (async)interactive-log / messages_response['replied']idempotent guardsreply flow
3. Decide CAA fires (no active agent)Chat-1 / core (CustomAgentAllocation guard)after message createCAA build or skiplogsUC3, CAA-S04
4. Case detect + resolve → envelopeChat-2 (this RFC)InteractiveBroadcastReply + CampaignContextsCAA build (flag ON)campaign_context envelopeempty envelope + logFR-1/FR-2, CAA-S01/S02/S04
5. Compose type/category + serialize contentChat-2 (this RFC)per broadcast10-field objectempty string for missingCAA-S03
6. Merge envelope + deliverChat-2 (this RFC) + existing CaaWorkerenriched payloadHTTPS POSTCaaWorker retry/circuit-breakerFR-1, CAA-S01
7. Consume campaign_context in autoreplyChatbot / Automation squadCAA POSTautoreply decisionchatbot-sideFR-3, CAA-S05 (out of scope)

Detail 2.E — State Surface Contract

EntityState fieldDefaultUpdated byRead viaStale window
CAA payloadcampaign_context envelope (trigger + broadcasts)absent (flag OFF) / empty envelope (flag ON, no context)CustomAgentAllocation#build_message (this RFC) per inbound replyCAA webhook POST (push)rebuilt every reply; replica-lag tolerance (§2.A)

3. High-Availability & Security

Read-only feature riding the existing HA CAA pipeline; no new stateful components.

  • Enrichment failure isolation: any error → empty campaign_context envelope + log; CAA still delivers.
  • Feature-flag / Redis outage: treat as flag OFF (→ campaign_context absent); Open Q#5.
  • Replica lag: a just-sent campaign may be omitted; captured on next reply.

Performance Requirement

  • Case 1: 1 pruned read + 1 template resolution. Case 2: 1 pruned window read + 1 batched broadcast→template load (cap 20). Target < 20ms added p99 to the (async) CAA build.

Monitoring & Alerting

  • Metrics (Services::Datadog::CaptureCustomMetric, cf. custom_agent_allocation.rb:23):
    • caa_campaign_context_enriched — tags organization_id, trigger, count, truncated:bool.
    • caa_campaign_context_skipped — reason tag (not_a_reply | no_campaign_in_room | flag_off | error).
  • Log [caa campaign enrichment]organization_id, room_id, trigger, count, truncated, reason, duration_ms. No raw body/content text.
  • Alert: caa_campaign_context_skipped{reason="error"} > 1% of CAA fires over 15 min → page Chat-2.

Logging

Counts/booleans only, never body/content text.

Security Implications

  • Threat model: cross-tenant leakage / wrong-URL delivery. Mitigated by organization_id+room_id scoping on every read and the existing per-org registered-URL lookup.
  • Tenancy isolation: both queries include organization_id (case 2 also room_id).

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeConstraintAudit trail
Chatbot / CAA consumerreceives custom_agent_allocation POSTreceive onlyown org onlyorg setting on; flag register_campaign_type_to_caaServices::Webhooks::EventLog
End customernone (triggers via reply)own roommessage row
Agent (human)nonewhen assigned, CAA does not fireparticipant records
  • Ownership: organization_id/room_id/reply_id derived from the message/room, never request input.
  • SQL injection: ActiveRecord parameterization. Static analysis: brakeman must pass.
  • ISO 27001/27701: no new PII column; campaign body already stored; delivered only to the org's own URL.

Detail 3.A — Failure Mode & Retry Catalog

CallTimeoutRetriesCircuit breakerDLQOn persistent failure
Chatbot HTTP (CAA POST) — existingPigeon request_timeoutretry:3 + backoffcaa_circuit_breaker + auto-disable org CAASidekiq dead queueorg CAA disabled (existing)
messages/broadcast/template reads (new, replica)inherits poolnonenonen/arescue → empty campaign_context envelope + log

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
Room assigned/resolved (bot stopped)CustomAgentAllocation#deliver (:19-20)CAA does not firenoneNo
Flag OFFCustomAgentAllocation#build_message (new)campaign_context absentnoneNo
Interactive-button reply (case 1)InteractiveBroadcastReplytrigger:interactive_button, 1 broadcastmetric enriched{interactive_button}No
Non-interactive reply w/ prior campaigns (case 2)CampaignContextstrigger:room_broadcasts, N broadcastsmetric enriched{room_broadcasts}No
Not a reply / no in-window campaignCampaignContexts → empty envelope{trigger:null,broadcasts:[]}metric skipped{...}No
Case-2 >20 campaignsrepo cap (Decision 7)oldest dropped; truncated:truelog + metricNo
Campaign message failed/deletedstatus filterexcludednoneNo

Detail 3.B — Error Response Catalog

N/A — no synchronous HTTP API. Enrichment errors fail-safe to an empty envelope; surfaced via logs/metrics.

Detail 3.C — Compliance & Data Governance

broadcasts[].message_body/message_content_text carry campaign copy already stored in messages, delivered only to the org's own CAA URL. No new PII column; no cross-border transfer. Confirm masking in the webhook event log (Open Q#6). Otherwise N/A — no new trigger.


4. Backwards Compatibility and Rollout Plan

Compatibility

  • Payload: additive campaign_context envelope; last_campaign preserved byte-identical (deprecated). campaign_context present whenever flag ON (empty envelope when no context); absent when flag OFF.
  • No schema change, no migration, no API version bump.
  • Notify Chatbot/Automation squad of the envelope + the last_campaign → campaign_context mapping (Decision 6) before enabling broadly.

Rollout Strategy

  • Sequence: merge BE (flag OFF) → seed flag (OFF) → enable internal/design-partner org(s) → chatbot ships CAA-S05 (reads campaign_context) → enable globally → (later) deprecate/remove last_campaign once no consumer reads it (Open Q#10).
  • Feature flag: register_campaign_type_to_caa | default OFF | org-scoped | kill-switch: disable → campaign_context stops immediately (last_campaign unaffected).
  • Stages:
    StageAudienceFlagGo/no-go
    1 — Internalinternal/design-partner org(s)ON for selectedrspec green; both cases + empty-envelope correct; skipped{error} ≈ 0; no CAA delivery regression
    2 — GAall orgsON globallyStage 1 clean ≥ 1 week; chatbot consuming (CAA-S05); no error spike
  • Stop conditions: skipped{error} > 1% of CAA fires; any CAA delivery-rate regression.
  • Rollback: flag OFF → campaign_context no longer emitted (instant). No data to unwind.

Detail 4.A — Configuration Contract

Flag / envTypeDefaultRequiredProvisionerSecret?
register_campaign_type_to_caapreference flag (Services::Preference)OFFYES — seed before enableInfra/PlatformNo
BROADCAST_LOOKBACK_WINDOW (optional reuse)duration string30.days (prod default mark_replied_worker.rb:115)NoInfraNo

Detail 4.B — Test Plan (commands sourced from repo)

Commands from hub_core/AGENTS.md. Prereqs: CATCH_WITH_ROLLBAR=true, .env.test, RabbitMQ OFF.

LayerCommand (source)Proves
Baselinecd hub_core && bundle exec rspec spec/apps/wa_cloud/interactors/agent_send_message_spec.rbsuite boots green
Unit — interactive detectcd hub_core && bundle exec rspec spec/apps/whatsapp/services/interactive_broadcast_reply_spec.rbbutton+reply_id+parent-broadcast_id → {broadcast_id, replied_button}; order-BUTTON w/o broadcast → nil; BUTTON_REPLY → nil; non-button → nil
Unit — type/category resolvercd hub_core && bundle exec rspec spec/apps/whatsapp/services/campaign_message_type_resolver_spec.rbcomposes message_type + message_category
Unit — serializercd hub_core && bundle exec rspec spec/apps/whatsapp/services/campaign_content_serializer_spec.rbbody/header+footer/media/buttons-ignored/carousel/missing-field
Unit — builder/entitycd hub_core && bundle exec rspec spec/core/domains/builders/webhooks/campaign_context_spec.rb10-field entity; campaign_sent_at == message.created_at; broadcast_id/name, template_id/name, replied_button
Unit — repocd hub_core && bundle exec rspec spec/core/domains/repositories/rooms/campaign_contexts_spec.rbcase1 → envelope {trigger:interactive_button, broadcasts:[1]} incl. replied_button; case2 → {trigger:room_broadcasts, broadcasts:[N newest-first, cap 20]} batched (Bullet-clean); no context → {trigger:null, broadcasts:[]}; UC4/boundary; org/room scoping; error → empty envelope
Unit — CAA servicecd hub_core && bundle exec rspec spec/core/domains/services/webhooks/custom_agent_allocation_spec.rbflag ON → campaign_context present (correct shape both cases + empty); flag OFF → absent (deep-equal today); last_campaign unchanged in all cases
Partition pruningcd hub_core/spec/dummy && bundle exec rails runner "puts Models::Message.where(...).where(created_at: 30.days.ago..Time.current).order(created_at: :desc).limit(20).explain"prunes; uses room_id
Lintcd hub_core && bundle exec rubocop --no-colorstyle clean
Securitycd hub_core && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -qno new warnings
Full suitecd hub_core && bundle exec rspecno regression

hub_service / hub_worker: no change — enrichment is entirely in hub_core.

Detail 4.C — Agent Execution Plan

OrderChunkFiles to modify/createCommandsAcceptance criteria (verifiable)
1Seed flag (OFF)Services::Preference.new.add(:register_campaign_type_to_caa, …)cd hub_core/spec/dummy && bundle exec rails runner "…; puts …enabled?(:register_campaign_type_to_caa)"prints false
2InteractiveBroadcastReply (case-1 detect + resolve + replied_button, pruned parent lookup)hub_core/app/apps/whatsapp/services/interactive_broadcast_reply.rb + specbundle exec rspec spec/apps/whatsapp/services/interactive_broadcast_reply_spec.rbreturns {broadcast_id, replied_button} for interactive reply; nil for order-BUTTON w/o broadcast / BUTTON_REPLY / non-button
3CampaignContentSerializerhub_core/app/apps/whatsapp/services/campaign_content_serializer.rb + specbundle exec rspec spec/apps/whatsapp/services/campaign_content_serializer_spec.rbbody/header+footer/media/buttons-ignored/carousel/missing→""
4CampaignMessageTypeResolver + Entities/Builders::Webhooks::CampaignContext (10 fields)apps/whatsapp/services/campaign_message_type_resolver.rb; core/domains/entities/webhooks/campaign_context.rb; core/domains/builders/webhooks/campaign_context.rb + specsbundle exec rspec spec/apps/whatsapp/services/campaign_message_type_resolver_spec.rb spec/core/domains/builders/webhooks/campaign_context_spec.rbtype/category composed; entity has all 10 fields; identity fields from broadcast/template
5Repositories::Rooms::CampaignContexts (cases 1/2 → envelope {trigger, broadcasts}; case-2 window + batched resolve + cap 20)hub_core/app/core/domains/repositories/rooms/campaign_contexts.rb + specbundle exec rspec spec/core/domains/repositories/rooms/campaign_contexts_spec.rbcase1 → {interactive_button,[1]}; case2 → {room_broadcasts,[N newest-first, cap 20]} batched Bullet-clean; no context → {null,[]}; UC4/boundary; org/room scope; failed/deleted excl; error → empty envelope; EXPLAIN pruning
6Extend CustomAgentAllocation (flag guard + merge campaign_context envelope; leave last_campaign)hub_core/app/core/domains/services/webhooks/custom_agent_allocation.rb + specbundle exec rspec spec/core/domains/services/webhooks/custom_agent_allocation_spec.rbflag ON → campaign_context present (both cases + empty); flag OFF → absent (deep-equal today); last_campaign identical in all cases
7Observability (metrics + log)custom_agent_allocation.rb / repobundle exec rspec spec/core/domains/services/webhooks/custom_agent_allocation_spec.rbenriched{trigger,count,truncated} on hit; skipped{reason} on skip; log excludes body/content
8Full verificationcd hub_core && bundle exec rubocop --no-color && bundle exec rspec && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -qrubocop 0; full suite green; brakeman clean

Detail 4.D — Verification & Rollback Recipe

Pre-merge (in order):

  1. cd hub_core && bundle exec rubocop --no-color
  2. cd hub_core && bundle exec rspec spec/apps/whatsapp/services/interactive_broadcast_reply_spec.rb spec/apps/whatsapp/services/campaign_content_serializer_spec.rb spec/apps/whatsapp/services/campaign_message_type_resolver_spec.rb spec/core/domains/builders/webhooks/campaign_context_spec.rb spec/core/domains/repositories/rooms/campaign_contexts_spec.rb spec/core/domains/services/webhooks/custom_agent_allocation_spec.rb
  3. cd hub_core/spec/dummy && bundle exec rails runner "puts Models::Message.where(organization_id:'…', room_id:'…', is_campaign:true).where(created_at:30.days.ago..Time.current).order(created_at: :desc).limit(20).explain" — confirm pruning
  4. cd hub_core && bundle exec rspec (full suite)
  5. cd hub_core && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q

Post-deploy signals:

  • Datadog caa_campaign_context_enriched{trigger=...} > 0 after test replies (one interactive, one non-interactive); skipped{reason="error"} ≈ 0.
  • Webhook event log for custom_agent_allocation shows the campaign_context envelope (correct shape) for the test org; last_campaign unchanged.
  • No regression in existing CAA delivery success rate.

Rollback:

  1. Services::Preference.new.disable(:register_campaign_type_to_caa)campaign_context stops immediately (last_campaign unaffected).
  2. Confirm via webhook event log; confirm caa_campaign_context_* metrics drop; CAA delivery at baseline within 15 min.
  3. No DDL/data rollback. If code revert needed: revert PR; flag OFF already neutralizes behaviour.

Detail 4.E — Resource & Cost Notes

Negligible — case 1: 1 read + 1 resolution; case 2: 1 window read + 1 batched resolution (cap 20), on an already-async path. No new pods, no write load, no storage growth, no new infra.


5. Concern, Questions, or Known Limitations

#TypeQuestion / LimitationOwnerDeadline
1Open QuestionCase-2 "all broadcast contexts": confirm the eligible set (is_campaign=true + status in [created,sent,delivered,read] within 30d) + newest-first cap 20.Chat-2 PM (Evelin) + ChatbotBefore GA
2Open QuestionVerify/add a composite index supporting the case-2 where(organization_id, room_id, is_campaign).where(created_at: range); record EXPLAIN pruning.Chat-2 BE + DBABefore chunk 5 merge
3Open QuestionCase-1 discriminator: confirm order-message BUTTON (webhook_receiver.rb:304) and interactive-list BUTTON_REPLY (:270-271) fall to case 2 (they do here).Chat-2 + ChatbotBefore Stage 2
4Doc inconsistencyPRD content-to-text table marks Header/Footer "not available"; CAA-S03 AC specifies "{header}: {body} — {footer}". This RFC follows the AC.Chat-2 PMRFC sign-off
5RiskServices::Preference failure mode must be treated as OFF (deliver today's payload), never 500.Chat-2 BEDuring chunk 6
6Open QuestionInfosec: mask message_body/message_content_text in Services::Webhooks::EventLog?InfosecRFC sign-off
7Known LimitationPer-card description not stored on enriched carousel cards → "".Chat-2/Commerce
8Known LimitationReplica lag: a campaign sent seconds before the reply may be omitted; captured on next reply.Chat-2 BEaccepted
9CoordinationChat-1 accepts the additive campaign_context envelope + event-log size.Chat-1 / InboxRFC sign-off
10Open Questionlast_campaign deprecation: agree a window for consumers to migrate to campaign_context (per Decision 6 mapping), then remove last_campaign in a follow-up. Not this RFC.Chat-1 + ChatbotPost-GA

6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-07RFC author (Isna)First draft (broad eligible-type scope).
2026-07-10rfc-reviewerScore 8.0 Strong / PROCEED with notes; flagged array-ordering + serializer format + N+1.
2026-07-13RFC author (Isna)rev2 — narrowed to a single quick-reply broadcast.
2026-07-13RFC author (Isna)rev3 — two cases (interactive-button single vs room-wide all); batched resolution removes N+1.
2026-07-13RFC author (Isna)rev4 — consistent payload format: unify both cases + the legacy last_campaign into a single campaign_context envelope ({ trigger, broadcasts:[…] }) with one canonical 10-field per-broadcast schema (superset of last_campaign, incl. replied_button for case 1). last_campaign preserved + deprecated with a field mapping (Decision 6, Open Q#10).
2026-07-13RFC author (Isna)rev5 — decisions completeness: restored full ADR treatment (Context · Options w/ pros-cons · Decision · Rationale · Consequences · Reversibility) to every decision (D1–D8) and added an explicit minimum-coverage block (caching / third-party / consistency / multi-tenancy / rollout gate). Detail 1.B now lists all 13. No behavioural change.

7. Ready for agent execution

no

Blocking items (external confirmations, not missing design):

  1. Open Q#1 — PM/Chatbot confirm the case-2 eligible set + cap.
  2. Open Q#3 — confirm order-message / BUTTON_REPLY fall to case 2.
  3. Open Q#4 — PM rules on header/footer serialization (chunk 3).
  4. Open Q#6 — infosec on masking body/content text in the webhook event log.
  5. Open Q#9 — Chat-1 accepts the additive campaign_context envelope.
  6. Infosec approver — Approver(s) metadata is TBD.

Non-blocking (handled in-plan / follow-up): #2 verified during chunk 5; #5 verified during chunk 6; #7/#8 accepted; #10 (last_campaign deprecation) is a post-GA follow-up.

Once items above are resolved, this RFC satisfies all execution-readiness gates:

  • ✅ Infrastructure Topology (no new infra)
  • ✅ Technical Decisions — 8 full ADR blocks (storage; windows; case discriminator; trigger/timing; serializer; consistent payload envelope; case-2 batched+capped; reuse-vs-new webhook) + a minimum-coverage block (caching / third-party / consistency / multi-tenancy / rollout gate), each with options + rationale
  • ✅ PRD-to-Schema Derivation — every field mapped + derivation point + PRD §
  • ✅ Detail 1.B/1.C — all 5 stories; CAA-S04 interactive discrimination; CAA-S05 cross-squad
  • ✅ Repo Reading Guide — anchors + Source Verification with file:line (interactive-log condition + resolution, button-shape edge cases, list-all pattern, legacy last_campaign shape)
  • ✅ Mermaid — topology, per-service, repo map, branch/skip (two cases → one envelope), ER, sequences (case 1, case 2, UC4, skip)
  • ✅ §2.3 DDL — N/A with justification; both partition-pruned queries; case-2 batched (no N+1); EXPLAIN gate
  • ✅ APIs — outbound CAA webhook: single campaign_context envelope with a consistent 10-field element schema + last_campaign→campaign_context mapping; extended (additive)
  • ✅ Data Integrity / Concurrency / Async specs (read-only, fail-safe; workers' writes untouched)
  • ✅ Responsibility Boundary Matrix (single cross-squad handoff = chatbot consumption)
  • ✅ Failure Mode & Branch/Skip catalogs; Error Response Catalog N/A-justified
  • ✅ Configuration Contract — flag register_campaign_type_to_caa, default OFF
  • ✅ Agent Execution Plan — 8 ordered chunks, files + commands + verifiable AC
  • ✅ Verification & Rollback Recipe — commands runnable (AGENTS.md-sourced), signals named, rollback = flag OFF