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
| Field | Value | Notes |
|---|---|---|
| Status | in-review (Qontak RFC governance: RFC) | content-repo linter enum; RFC→in-review remap |
| Owner | Revenue Chat-2 | Squad owning this feature |
| Author(s) | Isna Rahmatul Khoir | Primary author (engineer named in PRD) |
| Reviewers | Chat-1 (Inbox/CAA infra), Chatbot/Automation squad, WA Cloud Team | Tech reviewers across affected squads |
| Approver(s) | TBD — tech lead + infosec approver | Required before AGREED |
| Submitted Date | 2026-07-07 | ISO-8601 |
| Last Updated | 2026-07-13 | ISO-8601 |
| Target Release | 2026-Q3 | Per PRD (26Q3) |
| Related Documents | PRD (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 |
| Discussion | TBD | Slack channel / thread URL |
Type: backend Sub-type: new-feature
Sections at a Glance
- Overview (incl. §1 PRD-to-Schema Derivation)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequence Diagrams → DDL → APIs → integrity/async)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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 singlecampaign_contextenvelope with one canonical, fully-qualified per-broadcast schema (see Decision 6 + §2.4). Case 1 = a 1-elementbroadcastsarray; 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"andcampaign_context.broadcastshas 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"andbroadcastshas 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_contextabsent (byte-compatible with today).last_campaignpresent and unchanged in all cases. Verifiable. - No schema migration; no extra external API call; every
messagesread partition-pruned; case-2 broadcast→template resolution is batched (no N+1). Verifiable by EXPLAIN + rspec.
Out of Scope
- Chatbot-side consumption (CAA-S05 / QC-23109) — Chatbot/Automation squad.
- 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). - Retroactive registration — new sends only; no backfill.
- The
message_interaction/receive_message_from_customerwebhook — unchanged. - Modifying the interactive-log / reply-recording writes — reused read-only.
BUTTON_REPLY/ order-messageBUTTON— fall to case 2 (Open Q#3).
Related Documents
- PRD: 26Q3 WhatsApp Campaign — Register Campaign Type to CAA (v1.1 FINAL)
- Epic QC-22896 · Stories QC-23105…QC-23109 (CAA-S01…S05)
- Sibling RFC — WA Campaign Product Card Carousel (Backend):
hub_core/docs/rfcs/rfc-wa-campaign-product-card-carousel-be.md.
Assumptions
- CAA = the
custom_agent_allocationwebhook (custom_agent_allocation.rb:17-20). - The interactive-broadcast-reply signal already exists (
customer.rb:60-63+message_broadcast_interactive_log_worker.rb:10-33). We reuse it read-only. - Ordering: CAA delivery is enqueued in
Creates::Customer#call(:40-43);MarkRepliedWorkerandMessageBroadcastInteractiveLogWorkerare 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. message_categoryexists on the template (message_template.rb:43-58); a unifiedmessage_typeis composed from template flags.
Dependencies
| Dependency | Type | Owner | Status | Blocking? |
|---|---|---|---|---|
CAA webhook contract accepts the additive campaign_context envelope | Contract | Chat-1 / Inbox backend | additive JSON; no schema change | NO |
Chatbot handler reads campaign_context (CAA-S05 / QC-23109) | Feature | Chatbot / Automation squad | to build | NO for BE delivery |
Feature flag register_campaign_type_to_caa seeded (default OFF) | Config | Infra/Platform via Services::Preference | to seed | YES |
Confirm last_campaign deprecation window (consumers migrate to campaign_context) | Coordination | Chat-1 + Chatbot | Open Q#10 | Before 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 rule | Sourced from (existing) | Exposed via (campaign_context…) | Derived / enforced where | Source (PRD §) |
|---|---|---|---|---|
| Case 1: reply to an interactive (quick-reply) broadcast | inbound messages.buttons[0]['type']=='BUTTON' + messages.reply_id; parent messages.raw_message['message_broadcast_id'] | .trigger="interactive_button" + 1-element .broadcasts | Whatsapp::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 .broadcasts | Repositories::Rooms::CampaignContexts (new) | FR-1, CAA-S01 |
| 30-day backdated window (UC4) | messages.created_at vs reply created_at | .broadcasts | window (reply.created_at - 30.days)..reply.created_at | FR-2, CAA-S02 |
| Broadcast identity | message_broadcasts.id, .name | .broadcasts[].broadcast_id, .broadcast_name | builder | FR-1 (consistency) |
| Template identity | message_templates.id, .name | .broadcasts[].template_id, .template_name | builder | FR-1 (consistency) |
message_type | message_templates.type,.message_type,.is_marketing_opt_in,.is_request_phone_number | .broadcasts[].message_type | CampaignMessageTypeResolver (new) | FR-1 |
message_category | message_templates.category | .broadcasts[].message_category | resolver | FR-1 |
campaign_sent_at | linked campaign messages.created_at | .broadcasts[].campaign_sent_at | builder | FR-1/FR-2 |
message_body | messages.text fallback message_templates.body | .broadcasts[].message_body | builder | FR-1, CAA-S03 |
message_content_text | message_templates.{header,body,footer} + header[:format] + raw_message['carousel'] | .broadcasts[].message_content_text | CampaignContentSerializer (new) | FR-1, CAA-S03 |
| Tapped button (case 1) | inbound messages.buttons[0]['text'] (= interactive log value) | .broadcasts[].replied_button | InteractiveBroadcastReply | CAA-S04 |
| Chatbot stops when room assigned/resolved → no enrichment | active Models::AgentParticipant guard (custom_agent_allocation.rb:19) | CAA does not fire | existing guard — unchanged | UC3, CAA-S04 |
| Feature-gated | Services::Preference.new.enabled?(:register_campaign_type_to_caa) | — | guard in CustomAgentAllocation | rollout |
Detail 1.A — PRD Traceability Matrix
Forward:
| PRD requirement | Service / artifact | RFC 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 window | CampaignContexts window | Decision 2 |
| CAA-S03 content serialization | CampaignContentSerializer | Decision 5 |
| CAA-S04 interactive discrimination | InteractiveBroadcastReply + resolver | Decision 3 |
| CAA-S05 chatbot consumption | Chatbot squad (external) | §2.D row 7 (out of scope) |
| Consistent payload format | single campaign_context envelope | Decision 6, §2.4 |
Reverse:
| New artifact | PRD need |
|---|---|
Whatsapp::Services::InteractiveBroadcastReply | case-1 detection + single-broadcast resolution + replied_button |
Repositories::Rooms::CampaignContexts | orchestrate cases 1/2; case-2 batched list-all |
Whatsapp::Services::CampaignMessageTypeResolver | message_type + message_category |
Whatsapp::Services::CampaignContentSerializer | message_content_text |
Entities/Builders::Webhooks::CampaignContext | the per-broadcast object (10 fields, consistent) |
campaign_context envelope in CustomAgentAllocation#build_message | consistent payload enrichment |
flag register_campaign_type_to_caa | staged rollout |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Reads | Writes | Status surface |
|---|---|---|---|---|
CAA webhook (custom_agent_allocation) | Chatbot/automation (external HTTP) | receives enriched payload (push) | — | campaign_context envelope |
| Inbox room | Web/Mobile FE | existing thread API (no change) | — | unchanged |
No new HTTP endpoint — only the outbound CAA webhook (push):
n/a — covered by webhook push.
Role Coverage
| PRD role | Authorization | Endpoints | Cross-tenant? | Audit trail |
|---|---|---|---|---|
| Chatbot / automation (webhook consumer) | org-registered URL + org setting custom_agent_allocation | receives CAA POST only | No — payload scoped to organization_id | Services::Webhooks::EventLog |
| End customer (reply sender) | n/a — inbound reply triggers CAA | — | No | message row |
| Agent (human) | if assigned → CAA does not fire | — | No | participant records |
PRD Section Coverage
| PRD section | Title | Where covered |
|---|---|---|
| Background / Root Cause | Problem | §1 |
| CAA Architecture UC1–UC4 | flows | §1, §2.2, §2.D |
| UC4 Timestamp Behavior | backdated | Decision 2 |
| FR-1/2/3 | requirements | §1 PRD-to-Schema, §2 |
| Content-to-text rules | serialization | Decision 5 |
| Message Types to Register | eligibility + interactive discrimination | Decision 3 |
| User Stories CAA-S01…S05 | stories | §1 Detail 1.C |
| Dependencies / Open Questions / Out of Scope | — | §1, §5 |
Detail 1.B — Key Decisions Summary
| # | Decision | Chosen option | §2 block |
|---|---|---|---|
| 1 | Storage of derived fields | Derive at read time from MessageTemplate — no column/migration | Decision 1 |
| 2 | Windows & queries | Case-1 single via reply_id → parent → message_broadcast_id; case-2 all via room window; both partition-pruned | Decision 2 |
| 3 | Case discriminator | Reuse the interactive-log condition (buttons[0].type=='BUTTON' + resolvable broadcast parent) → case 1; else case 2 | Decision 3 |
| 4 | Trigger surface & timing | Enrich synchronously in CustomAgentAllocation#build_message, resolving from the inbound message | Decision 4 |
| 5 | Content-to-text serialization | New stateless CampaignContentSerializer | Decision 5 |
| 6 | Consistent payload format | Single campaign_context envelope ({ trigger, broadcasts:[…] }), one canonical per-broadcast schema (superset of last_campaign); last_campaign preserved + deprecated | Decision 6 |
| 7 | Case-2 multiplicity & cost | All in-window campaigns, batched broadcast→template resolution, newest-first, cap 20 (no N+1) | Decision 7 |
| 8 | Reuse vs new webhook event | Extend the existing custom_agent_allocation event (no new endpoint/event) | Decision 8 |
| 9 | Caching | none — 1–2 pruned reads on an async path; cache adds invalidation risk for negligible gain | Minimum-coverage decisions |
| 10 | Third-party integration | none added — reuse the existing CaaWorker HTTP sender | Minimum-coverage decisions |
| 11 | Consistency model | Read-time derivation → strongly consistent with template state (only replica lag) | Minimum-coverage decisions |
| 12 | Multi-tenancy isolation | Enforced in the new reads (organization_id + room_id) + org-scoped URL lookup | Minimum-coverage decisions |
| 13 | Rollout gate | Feature flag register_campaign_type_to_caa (default OFF, org-scoped) | Minimum-coverage decisions |
Detail 1.C — Per-Story Change Map
| Story # | Title | Layer scope | BE changes | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|
| CAA-S01 (QC-23105) | Webhook enriched (UC2) | BE-only | CampaignContexts repo (both cases); CustomAgentAllocation merges the campaign_context envelope; entity/builder | rspec: 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-only | window 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 serialized | BE-only | CampaignContentSerializer (body/media/buttons-ignored/carousel/missing→"") | rspec truth-table | Decision 5; §4.C ch.3 |
| CAA-S04 (QC-23108) | Interactive discrimination + eligibility | BE-only | InteractiveBroadcastReply (case-1 detect + replied_button); resolver; case-2 is_campaign+status filter | rspec: interactive-button reply → single button broadcast; non-interactive → room set; follow_up/authentication excluded | Decision 3; §2.1; §4.C ch.2 |
| CAA-S05 (QC-23109) | Chatbot consumes context | Cross-squad | n/a — Chatbot/Automation squad; BE guarantees the consistent campaign_context envelope + backward compat | rspec (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 existingget_last_campaign(custom_agent_allocation.rb:83-89). - Cons: a small read at CAA time (bounded — Decisions 2/7).
- Pros: zero migration; no write-path change; always consistent with the template's current
category/flags; the provenance link already exists (
- 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 tocreated_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; windowmark_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 backdatedcreated_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
..).
- Pros: identical to code already in production (low risk); partition-pruned; anchoring on the reply's
- Option B — anchor on
room.created_at(or use the worker's unprunedfind_by(id:)).- Cons:
room.created_atmis-sizes the window for UC2 (campaign into an older open room); an unprunedfind_by(id:)scans all partitions. Rejected.
- Cons:
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 resolvablereply_id → parent → raw_message['message_broadcast_id'](message_broadcast_interactive_log_worker.rb:16-19); also returnsreplied_button = message.buttons.first['text'](= interactive-logvalue).- 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
buttonsfor aQUICK_REPLYentry (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).
- 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
- 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 existingget_last_campaignruns,:46), resolving the linkage from the inbound message itself (it already carriesbuttonsandreply_id, set pre-create attransaction_customer_send_message.rb:302).- Pros:
build_messageis 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; keepsCaaWorkera pure sender. - Cons: adds 1–2 replica reads to the payload-build path (negligible vs the ES/Redis calls already there).
- Pros:
- 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.
- Cons: unsafe — CAA delivery is enqueued (
- 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.
- Pros: single tested unit; matches the stateless-service pattern (
- 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_contextenvelope{ trigger, broadcasts: [ {…10 fields…} ] }; same element schema for both cases;last_campaignkept 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) untillast_campaignis removed (Open Q#10).
- Pros: one shape the chatbot always parses; case 1 = 1-element array, case 2 = N; per-broadcast
schema is a superset of
- Option B — two loose top-level keys (
campaign_messagesarray +campaign_context_triggerstring), as in rev3.- Cons: the discriminator floats separately from the data; still inconsistent with
last_campaign; two shapes to document.
- Cons: the discriminator floats separately from the data; still inconsistent with
- Option C — re-purpose
last_campaignto carry the new fields.- Cons: breaks existing consumers relying on the current
last_campaignshape/semantics; thelast_*naming is inconsistent with the PRD field names; rejected.
- Cons: breaks existing consumers relying on the current
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):
| Field | Type | Source | Notes |
|---|---|---|---|
broadcast_id | uuid | MessageBroadcast.id | supersedes last_campaign.last_campaign_id |
broadcast_name | string | MessageBroadcast.name | supersedes last_campaign_name |
template_id | uuid | message_template.id | supersedes last_sent_template_id |
template_name | string | message_template.name | supersedes last_sent_template_name |
message_type | string enum | composed | PRD field |
message_category | string enum | message_templates.category | PRD field |
campaign_sent_at | string (ISO-8601 UTC) | messages.created_at | PRD field; supersedes last_sent_at (which was never set) |
message_body | string (may be "") | messages.text / template body | PRD field |
message_content_text | string (may be "") | serializer | PRD field |
replied_button | string | null | inbound buttons[0]['text'] | case 1 only (the tapped button); null in case 2 |
last_campaign → campaign_context field mapping (for consumer migration):
Legacy last_campaign | Canonical campaign_context.broadcasts[] |
|---|---|
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 (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 oneModels::MessageBroadcast.where(id: ids).includes(:message_template).- Pros: satisfies "send all"; one batched query (Bullet-clean); the cap bounds pathological rooms; the
statusfilter (created/sent/delivered/read) +is_campaignnaturally exclude follow-up (is_campaign=false) and authentication (never in a room). - Cons: >20 campaigns in 30 days are truncated (logged +
truncated:truemetric — no silent cap).
- Pros: satisfies "send all"; one batched query (Bullet-clean); the cap bounds pathological rooms; the
- 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_allocationpayload (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 byroom_id + reference-minutelater 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 viaWebhooks::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/CampaignContextsincludesorganization_id(case 2 alsoroom_id) per the AGENTS.md rule; the destination URL set comes from the org-scopedServices::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_contextstops;last_campaignunaffected). UsesServices::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
| Path | Why the agent reads it | What it teaches |
|---|---|---|
hub_core/app/core/domains/services/webhooks/custom_agent_allocation.rb | CAA 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.rb | interactive-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.rb | case-1 resolution: reply_id → parent → raw_message['message_broadcast_id'] (:16-19); value = button text | single-broadcast linkage + replied_button; parent find_by(id:) unpruned (:16) |
hub_core/app/core/workers/message_broadcasts/mark_replied_worker.rb | window shape: window (:122), query (:132) | partition-pruned (reply-30d..reply) + is_campaign filter |
hub_core/app/core/domains/services/metrics/room_participants/conversation_sessions.rb | list-ALL campaigns in a room within a window (:15) | case-2 array shape |
hub_core/app/apps/wa_cloud/services/webhook_receiver.rb | button 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.rb | fetch_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.rb | belongs_to :message_template (:12); id, name | broadcast identity fields + join |
hub_core/app/core/domains/models/message_template.rb | buttons, 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.rb | outbound 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.rb | payload builder + last_campaign slot (:62-64 / :29) | builder/entity pattern; where the envelope merges |
hub_core/app/core/domains/services/preference_v2.rb | Services::Preference#enabled? (:20-76) | feature-flag guard |
Existing Contracts to Reuse, Extend, or Replace
| Contract | Status | Justification | Owner |
|---|---|---|---|
CAA webhook custom_agent_allocation payload | extended | additive 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) + deprecated | kept byte-identical for existing consumers; superseded by campaign_context (Decision 6) | Chat-2 / Chat-1 |
| Interactive-log condition + resolution | reused (pattern) | extracted into InteractiveBroadcastReply; worker/model untouched | Chat-2 |
| Reply-window / list-all queries | reused (pattern) | mirror the shapes | Chat-2 |
messages/message_broadcasts/message_templates | reused (read-only) | read-time derivation, no DDL | Chat-2 |
Creates::Customer fan-out & CaaWorker | reused | no change | Chat-2 |
Services::Preference flag | new-with-justification | register_campaign_type_to_caa (default OFF) | Infra/Platform |
Patterns to Follow
| Concern | Pattern | Reference file | Deviation |
|---|---|---|---|
| Repository / DB access | AbstractRepository, Dry::Monads[:result], success/failure, switch_replica_db | abstract_repository.rb:3,23,28-29,59 | None |
| Entity | Entities::AbstractEntity < Dry::Struct | entities/abstract_entity.rb:3; entities/message_by_webhook.rb | None |
| Builder | Builders::AbstractBuilder; returns plain entity | builders/abstract_builder.rb:3,9 | None |
| 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:15 | None |
| Feature flag | Services::Preference.new.enabled?(:flag, organization_id: id) | preference_v2.rb:20-76 | None |
| Webhook payload merge | Builders::MessageByWebhook.build.attributes.merge(...) | custom_agent_allocation.rb:44-47 | extend with campaign_context |
Reading Order for the Agent
hub_core/AGENTS.md— layers, feature-flag API, partition-pruning, Bullet/N+1, test prereqs.custom_agent_allocation.rb— extension point + thelast_campaignshape to stay consistent with.repositories/messages/creates/customer.rb:39-43,60-63,279-295— trigger + case-1 condition + ordering.message_broadcast_interactive_log_worker.rb:10-33— case-1 resolution +value/replied_button.mark_replied_worker.rb:115-148+conversation_sessions.rb:15— window + list-all.wa_cloud/services/webhook_receiver.rb:231,250-252,270-271,304— button shapes + edge cases.models/message_broadcast.rb+message_template.rb— identity fields + type/category.create.rb:154-164+user_send_hsm.rb:155-177— content structure + media branching.
Source Verification (anti-hallucination)
| Anchor / claim | Verified by | Evidence |
|---|---|---|
| CAA fires only when no active agent + not comment room | read | custom_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) | read | customer.rb:60-63 `if message.buttons&.first&.dig('type') == 'BUTTON' && (message.reply_id.present? |
Case-1 resolution + replied_button | read | message_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) | read | customer.rb:39/:40-43/:60-63/:295 |
| Reply-window shape | read | mark_replied_worker.rb:115/:122/:132 |
| List-ALL campaigns in room (case 2) | read | conversation_sessions.rb:15 Models::Message.where(room_id:..., is_campaign: true, created_at: time_delta..Time.zone.now) |
| Inbound button shapes + context | read | webhook_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 create | read | transaction_customer_send_message.rb:302,:557-580 (pruned + widening fallback) |
| Broadcast identity + template join | read | message_broadcast.rb:12 belongs_to :message_template; MessageBroadcast.name; create_message_from_campaign.rb:209 { message_broadcast_id: } |
| Template flags + name | read | message_template.rb:19-22/:24-26/:43-58/:130-136; .name |
| No existing content-to-text serializer | grep (negative) | none in app/ |
| Feature-flag API | read + AGENTS.md | preference_v2.rb:20-76 |
| Repo/entity/builder base classes | read | abstract_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 inmessages; 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
| Event | Direction | Method | AuthN/AuthZ | Consumer | Payload change | Idempotency | Versioning |
|---|---|---|---|---|---|---|---|
custom_agent_allocation (CAA) | outbound (hub_core → chatbot) | POST (Webhooks::CaaWorker) | org-registered URL + org setting enabled | Chatbot/automation | additive: single campaign_context envelope; last_campaign preserved (deprecated) | consumer-side; same body across retries | additive — no version bump |
campaign_context envelope:
| Key | Type | Presence | Notes |
|---|---|---|---|
campaign_context | object | present whenever flag ON; absent when flag OFF | { trigger, broadcasts } |
campaign_context.trigger | string enum | null | — | "interactive_button" (case 1) | "room_broadcasts" (case 2) | null (no context) |
campaign_context.broadcasts | array 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):
| Field | Type | Nullability | Notes |
|---|---|---|---|
broadcast_id | uuid string | non-null | MessageBroadcast.id |
broadcast_name | string | may be null | MessageBroadcast.name |
template_id | uuid string | may be null | message_template.id |
template_name | string | may be null | message_template.name |
message_type | string enum | non-null | campaign|follow_up|marketing_opt_in|phone_number_request|carousel (composed) |
message_category | string enum | non-null | utility|marketing |
campaign_sent_at | string (ISO-8601 UTC) | non-null | linked campaign messages.created_at |
message_body | string | may be "" | rendered body / template body |
message_content_text | string | may be "" | serialized content (Decision 5) |
replied_button | string | null in case 2 | case 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 context — campaign_context: { "trigger": null, "broadcasts": [] }.
Flag OFF — campaign_context absent (byte-identical to today).
Detail 2.A — Data Integrity Matrix
| Write path | Transaction scope | Partial failure | Idempotency | Consistency | Stale read |
|---|---|---|---|---|---|
| Enrichment (this RFC) | None — read-only | resolve/serialize error → campaign_context: {trigger:null,broadcasts:[]} + log; CAA still delivers | per-delivery uniq_id (custom_agent_allocation.rb:54) | read-time derivation → consistent with template state | replica 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
| Resource | Writers | Collision | Resolution | On conflict |
|---|---|---|---|---|
| CAA payload build | rapid successive replies in same room | each builds its own payload | none needed — read-only; per-delivery uniq_id | independent, idempotent on consumer |
messages read vs broadcast insert | enrichment read vs campaign write | replica lag may omit a very fresh campaign | replica snapshot | captured on next reply |
Detail 2.C — Async Job / Event Consumer Spec
| Job/Consumer | Trigger | Input | Retry | DLQ | Concurrency | Idempotency | Timeout | Poison |
|---|---|---|---|---|---|---|---|---|
Webhooks::CaaWorker (existing, unchanged) | perform_async from CustomAgentAllocation | (event, uniq_id, url, headers, data.as_json) incl. campaign_context | retry:3 + backoff (caa_worker.rb:6-16) | Sidekiq dead queue | queue :custom_agent_allocation | consumer-side (same body) | Pigeon request_timeout | auto-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/a | inherits CAA path | n/a (read) | inherits caller | rescue + CustomLogFormat.error |
MessageBroadcastInteractiveLogWorker / MarkRepliedWorker (existing — unchanged; linkage reused) | perform_async from customer.rb:60-63/:295 | existing | existing | existing | existing | existing | existing | existing |
Detail 2.D — Responsibility Boundary Matrix
| Step | Owner | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 1. Receive inbound reply, create message | Chat-1 / channel | Meta webhook → Kafka | message row; fan-out | inbound retry worker | UC2/UC4 |
| 2. Record interactive-log / reply (existing) | Chat-1 / core (workers) | customer.rb:60-63/:295 (async) | interactive-log / messages_response['replied'] | idempotent guards | reply flow |
| 3. Decide CAA fires (no active agent) | Chat-1 / core (CustomAgentAllocation guard) | after message create | CAA build or skip | logs | UC3, CAA-S04 |
| 4. Case detect + resolve → envelope | Chat-2 (this RFC) — InteractiveBroadcastReply + CampaignContexts | CAA build (flag ON) | campaign_context envelope | empty envelope + log | FR-1/FR-2, CAA-S01/S02/S04 |
| 5. Compose type/category + serialize content | Chat-2 (this RFC) | per broadcast | 10-field object | empty string for missing | CAA-S03 |
| 6. Merge envelope + deliver | Chat-2 (this RFC) + existing CaaWorker | enriched payload | HTTPS POST | CaaWorker retry/circuit-breaker | FR-1, CAA-S01 |
7. Consume campaign_context in autoreply | Chatbot / Automation squad | CAA POST | autoreply decision | chatbot-side | FR-3, CAA-S05 (out of scope) |
Detail 2.E — State Surface Contract
| Entity | State field | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| CAA payload | campaign_context envelope (trigger + broadcasts) | absent (flag OFF) / empty envelope (flag ON, no context) | CustomAgentAllocation#build_message (this RFC) per inbound reply | CAA 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_contextenvelope + log; CAA still delivers. - Feature-flag / Redis outage: treat as flag OFF (→
campaign_contextabsent); 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— tagsorganization_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_idscoping on every read and the existing per-org registered-URL lookup. - Tenancy isolation: both queries include
organization_id(case 2 alsoroom_id).
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | Constraint | Audit trail |
|---|---|---|---|---|---|
| Chatbot / CAA consumer | receives custom_agent_allocation POST | receive only | own org only | org setting on; flag register_campaign_type_to_caa | Services::Webhooks::EventLog |
| End customer | none (triggers via reply) | — | own room | — | message row |
| Agent (human) | none | — | — | when assigned, CAA does not fire | participant records |
- Ownership:
organization_id/room_id/reply_idderived from the message/room, never request input. - SQL injection: ActiveRecord parameterization. Static analysis:
brakemanmust 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
| Call | Timeout | Retries | Circuit breaker | DLQ | On persistent failure |
|---|---|---|---|---|---|
| Chatbot HTTP (CAA POST) — existing | Pigeon request_timeout | retry:3 + backoff | caa_circuit_breaker + auto-disable org CAA | Sidekiq dead queue | org CAA disabled (existing) |
messages/broadcast/template reads (new, replica) | inherits pool | none | none | n/a | rescue → empty campaign_context envelope + log |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
| Room assigned/resolved (bot stopped) | CustomAgentAllocation#deliver (:19-20) | CAA does not fire | none | No |
| Flag OFF | CustomAgentAllocation#build_message (new) | campaign_context absent | none | No |
| Interactive-button reply (case 1) | InteractiveBroadcastReply | trigger:interactive_button, 1 broadcast | metric enriched{interactive_button} | No |
| Non-interactive reply w/ prior campaigns (case 2) | CampaignContexts | trigger:room_broadcasts, N broadcasts | metric enriched{room_broadcasts} | No |
| Not a reply / no in-window campaign | CampaignContexts → empty envelope | {trigger:null,broadcasts:[]} | metric skipped{...} | No |
| Case-2 >20 campaigns | repo cap (Decision 7) | oldest dropped; truncated:true | log + metric | No |
Campaign message failed/deleted | status filter | excluded | none | No |
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_contextenvelope;last_campaignpreserved byte-identical (deprecated).campaign_contextpresent 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_contextmapping (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/removelast_campaignonce no consumer reads it (Open Q#10). - Feature flag:
register_campaign_type_to_caa| default OFF | org-scoped | kill-switch: disable →campaign_contextstops immediately (last_campaignunaffected). - Stages:
Stage Audience Flag Go/no-go 1 — Internal internal/design-partner org(s) ON for selected rspec green; both cases + empty-envelope correct; skipped{error}≈ 0; no CAA delivery regression2 — GA all orgs ON globally Stage 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_contextno longer emitted (instant). No data to unwind.
Detail 4.A — Configuration Contract
| Flag / env | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|
register_campaign_type_to_caa | preference flag (Services::Preference) | OFF | YES — seed before enable | Infra/Platform | No |
BROADCAST_LOOKBACK_WINDOW (optional reuse) | duration string | 30.days (prod default mark_replied_worker.rb:115) | No | Infra | No |
Detail 4.B — Test Plan (commands sourced from repo)
Commands from hub_core/AGENTS.md. Prereqs: CATCH_WITH_ROLLBAR=true, .env.test, RabbitMQ OFF.
| Layer | Command (source) | Proves |
|---|---|---|
| Baseline | cd hub_core && bundle exec rspec spec/apps/wa_cloud/interactors/agent_send_message_spec.rb | suite boots green |
| Unit — interactive detect | cd hub_core && bundle exec rspec spec/apps/whatsapp/services/interactive_broadcast_reply_spec.rb | button+reply_id+parent-broadcast_id → {broadcast_id, replied_button}; order-BUTTON w/o broadcast → nil; BUTTON_REPLY → nil; non-button → nil |
| Unit — type/category resolver | cd hub_core && bundle exec rspec spec/apps/whatsapp/services/campaign_message_type_resolver_spec.rb | composes message_type + message_category |
| Unit — serializer | cd hub_core && bundle exec rspec spec/apps/whatsapp/services/campaign_content_serializer_spec.rb | body/header+footer/media/buttons-ignored/carousel/missing-field |
| Unit — builder/entity | cd hub_core && bundle exec rspec spec/core/domains/builders/webhooks/campaign_context_spec.rb | 10-field entity; campaign_sent_at == message.created_at; broadcast_id/name, template_id/name, replied_button |
| Unit — repo | cd hub_core && bundle exec rspec spec/core/domains/repositories/rooms/campaign_contexts_spec.rb | case1 → 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 service | cd hub_core && bundle exec rspec spec/core/domains/services/webhooks/custom_agent_allocation_spec.rb | flag ON → campaign_context present (correct shape both cases + empty); flag OFF → absent (deep-equal today); last_campaign unchanged in all cases |
| Partition pruning | cd 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 |
| Lint | cd hub_core && bundle exec rubocop --no-color | style clean |
| Security | cd hub_core && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q | no new warnings |
| Full suite | cd hub_core && bundle exec rspec | no regression |
hub_service / hub_worker: no change — enrichment is entirely in hub_core.
Detail 4.C — Agent Execution Plan
| Order | Chunk | Files to modify/create | Commands | Acceptance criteria (verifiable) |
|---|---|---|---|---|
| 1 | Seed 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 |
| 2 | InteractiveBroadcastReply (case-1 detect + resolve + replied_button, pruned parent lookup) | hub_core/app/apps/whatsapp/services/interactive_broadcast_reply.rb + spec | bundle exec rspec spec/apps/whatsapp/services/interactive_broadcast_reply_spec.rb | returns {broadcast_id, replied_button} for interactive reply; nil for order-BUTTON w/o broadcast / BUTTON_REPLY / non-button |
| 3 | CampaignContentSerializer | hub_core/app/apps/whatsapp/services/campaign_content_serializer.rb + spec | bundle exec rspec spec/apps/whatsapp/services/campaign_content_serializer_spec.rb | body/header+footer/media/buttons-ignored/carousel/missing→"" |
| 4 | CampaignMessageTypeResolver + 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 + specs | bundle exec rspec spec/apps/whatsapp/services/campaign_message_type_resolver_spec.rb spec/core/domains/builders/webhooks/campaign_context_spec.rb | type/category composed; entity has all 10 fields; identity fields from broadcast/template |
| 5 | Repositories::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 + spec | bundle exec rspec spec/core/domains/repositories/rooms/campaign_contexts_spec.rb | case1 → {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 |
| 6 | Extend CustomAgentAllocation (flag guard + merge campaign_context envelope; leave last_campaign) | hub_core/app/core/domains/services/webhooks/custom_agent_allocation.rb + spec | bundle exec rspec spec/core/domains/services/webhooks/custom_agent_allocation_spec.rb | flag ON → campaign_context present (both cases + empty); flag OFF → absent (deep-equal today); last_campaign identical in all cases |
| 7 | Observability (metrics + log) | custom_agent_allocation.rb / repo | bundle exec rspec spec/core/domains/services/webhooks/custom_agent_allocation_spec.rb | enriched{trigger,count,truncated} on hit; skipped{reason} on skip; log excludes body/content |
| 8 | Full verification | — | cd hub_core && bundle exec rubocop --no-color && bundle exec rspec && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q | rubocop 0; full suite green; brakeman clean |
Detail 4.D — Verification & Rollback Recipe
Pre-merge (in order):
cd hub_core && bundle exec rubocop --no-colorcd 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.rbcd 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 pruningcd hub_core && bundle exec rspec(full suite)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_allocationshows thecampaign_contextenvelope (correct shape) for the test org;last_campaignunchanged. - No regression in existing CAA delivery success rate.
Rollback:
Services::Preference.new.disable(:register_campaign_type_to_caa)—campaign_contextstops immediately (last_campaignunaffected).- Confirm via webhook event log; confirm
caa_campaign_context_*metrics drop; CAA delivery at baseline within 15 min. - 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
| # | Type | Question / Limitation | Owner | Deadline |
|---|---|---|---|---|
| 1 | Open Question | Case-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) + Chatbot | Before GA |
| 2 | Open Question | Verify/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 + DBA | Before chunk 5 merge |
| 3 | Open Question | Case-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 + Chatbot | Before Stage 2 |
| 4 | Doc inconsistency | PRD content-to-text table marks Header/Footer "not available"; CAA-S03 AC specifies "{header}: {body} — {footer}". This RFC follows the AC. | Chat-2 PM | RFC sign-off |
| 5 | Risk | Services::Preference failure mode must be treated as OFF (deliver today's payload), never 500. | Chat-2 BE | During chunk 6 |
| 6 | Open Question | Infosec: mask message_body/message_content_text in Services::Webhooks::EventLog? | Infosec | RFC sign-off |
| 7 | Known Limitation | Per-card description not stored on enriched carousel cards → "". | Chat-2/Commerce | — |
| 8 | Known Limitation | Replica lag: a campaign sent seconds before the reply may be omitted; captured on next reply. | Chat-2 BE | accepted |
| 9 | Coordination | Chat-1 accepts the additive campaign_context envelope + event-log size. | Chat-1 / Inbox | RFC sign-off |
| 10 | Open Question | last_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 + Chatbot | Post-GA |
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-07 | RFC author (Isna) | First draft (broad eligible-type scope). |
| 2026-07-10 | rfc-reviewer | Score 8.0 Strong / PROCEED with notes; flagged array-ordering + serializer format + N+1. |
| 2026-07-13 | RFC author (Isna) | rev2 — narrowed to a single quick-reply broadcast. |
| 2026-07-13 | RFC author (Isna) | rev3 — two cases (interactive-button single vs room-wide all); batched resolution removes N+1. |
| 2026-07-13 | RFC 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-13 | RFC 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):
- Open Q#1 — PM/Chatbot confirm the case-2 eligible set + cap.
- Open Q#3 — confirm order-message /
BUTTON_REPLYfall to case 2. - Open Q#4 — PM rules on header/footer serialization (chunk 3).
- Open Q#6 — infosec on masking body/content text in the webhook event log.
- Open Q#9 — Chat-1 accepts the additive
campaign_contextenvelope. - 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_campaignshape) - ✅ 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_contextenvelope with a consistent 10-field element schema +last_campaign→campaign_contextmapping;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