RFC: WhatsApp Campaign — Send Campaign with Segmentation (Backend, Phase 1)
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: every section maps to real file paths verified in the repository. §7 lists the remaining gates.
Do not delete the mermaid diagrams when this doc is pasted into Confluence (reviewer feedback, 2026-06-30). If a diagram makes a section too long, wrap it in a
/expandblock instead of removing it — agents and reviewers both rely on the diagrams to parse the flow.The YAML frontmatter at the very top is the machine-readable index agents parse. The metadata table below is the human-readable governance record. Both must agree on every shared field.
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | RFC | IDEA / RFC / ABANDON / AGREED |
| Owner | Revenue (Chat-2) | |
| Author(s) | Hilmi Dama | |
| Reviewers | Burhanudin Hakim, Fachriza Ramanda | |
| Approver(s) | Tech Lead Revenue (Chat-2), Infosec Approver (TBD) | |
| Submitted Date | 2026-06-18 | |
| Last Updated | 2026-07-01 | |
| Target Release | 2026-Q2 | |
| Related Documents | PRD · Async 1-pager · CDP API Design | |
| Discussion | TBD |
Type: backend
Sub-type: new-feature
Sections at a Glance
- Overview (PRD-to-Schema Derivation, story map, decisions index)
- Technical Design (Infrastructure Topology → ADR Technical Decisions → Repo Reading Guide → Architecture → Sequence Diagrams → DDL → APIs → Async Spec)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (Agent Execution Plan + Verification Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- Ready for agent execution
1. Overview
The current WhatsApp Campaign flow requires a user to manually create a static Recipient List before launching a campaign. This initiative enables users to select a CDP Customer Segment as their audience directly from the campaign create form. The backend generates the corresponding ContactListRecipient records asynchronously from the CDP segment so the campaign can broadcast at the scheduled time.
Scope of this RFC (Phase 1 only):
- Segment-based recipient generation for segments with ≤ 20 000 WhatsApp-eligible customers.
- Minimum 1-hour schedule window enforcement.
- Balance validation with 10 % buffer (using estimated count from CDP).
- Recipient list exposure for Campaign Detail drawer.
- Datadog tracking for audience source.
Phase 2 (automatic campaign batching for segments > 20 000 recipients) is explicitly deferred. See PRD §Phase 2.
Success Criteria
- A campaign created with a segment audience has its
ContactListRecipientrows fully populated beforescheduled_at(1-hour buffer). - Campaigns with insufficient balance are rejected before any
ContactListrecord is created. - Worker retries on CDP API failures;
contact_list.progresstransitions tofailureafter all retries exhausted, and campaign execution is blocked. rspecsuite green on all new specs (no mocks except at CDP HTTP boundary).- Datadog metric
upload_contact_from_segment_statusemitted withstatus:successorstatus:failuretag on every worker completion.
Out of Scope
- Phase 2: segment > 20 000 recipients / campaign batch-splitting (PRD Phase 2 page).
- Frontend changes (stories 1–8) — covered in separate FE RFC.
- Segment CRUD (owned by CDP team).
- Recurring campaign option with segment audience — disabled at validation; no backend split logic.
- Right-to-delete / GDPR purge of generated recipient data — deferred.
Related Documents
- PRD: 26Q2 WhatsApp Campaign: Send Campaign with Segmentation
- Async 1-pager: Async process create segmentation contact list
- CDP Technical API Design: Technical Design — APIs
Assumptions
- CDP team ships
GET /api/v1/segments/:segment_id/customers(S2S BasicAuth) before hub-core integration begins. This is a hard dependency. source_typecolumn already exists oncontact_lists(verified:CreateDirectSelectAllsetssource_type: 'contacts'atapp/core/domains/repositories/contact_lists/create_direct_select_all.rb:19).ContactListRecipient(version 2) is the canonical recipient row —decoupling_recipient_listflag is always enabled for segment-based lists.- CDP segment membership is a snapshot taken at recipient creation time — segment changes between submit and send do not affect the generated list.
estimated_recipient_count(thereachability.whatsapp.countfrom CDP detail endpoint) is provided by the FE as a parameter. Hub-core does not call CDP to re-fetch this count during campaign creation — it trusts the FE-passed value for balance validation.- CDP API pagination: offset-based (
page/per_pagemax 100), confirmed. The 20,000-recipient Phase 1 cap makes offset-drift risk negligible. Cursor-based pagination is NOT used. CDP customer— retracted 2026-06-30. Reviewer feedback (Jovi Renaldo, Isna Rahmatul Khoir) states CDP segmentation only carriesid(UUID) can be used directly asaccount_uniq_id(BSUID)phoneandemailper customer, not a hub_core contact identifier. Do not build Chunk 4 against the old assumption — see Decision 8 (REOPENED) in §Technical Decisions and Open Q #7.
Dependencies
| Dependency | Owner | Status |
|---|---|---|
GET /api/v1/segments/:segment_id/customers (CDP S2S) | CDP team | Needed — confirm readiness |
| CDP BasicAuth credentials in hub environment secrets | Infra/Platform | Needed |
Feature flag send_campaign_with_segment | Chat-2 | New — register at deploy |
Existing decoupling_recipient_list feature flag | Platform | Assumed always ON for segment lists |
Confirmation of CDP customer identity field shape (id/phone/email only?) | CDP team | Needed — blocking Chunk 4, see Decision 8 |
CDP org-scoping on segment detail lookup (for IDOR check on segment_id) | CDP team | Needed — see Decision 14 |
PRD-to-Schema Derivation (backend-specific — required)
| PRD-described entity / attribute / rule | Persisted as (table.column) | Exposed via (endpoint / event) | Enforced where | Source |
|---|---|---|---|---|
| Segment is selected as audience source | contact_lists.source_type = 'segment' | Campaign detail response includes audience_type | Repositories::ContactLists::CreateFromSegment | PRD §Phase 1 |
| CDP segment ID linked to contact list | contact_lists.segment_id :string (new) | contact_lists.segment_id readable by campaign queries | migration + Models::ContactList | Async 1-pager §Data Model |
| Recipient list name = segment name | contact_lists.name = segment_name | contact_lists.name in recipient list API | CreateFromSegment | PRD Story 10, scenario 10.7 |
| Async recipient generation from CDP | contact_list_recipients rows via CreateRecipientFromSegmentWorker | progress field on ContactList | Worker + CreateFromSegmentProcess | PRD Story 10 |
| Max 20 000 recipients (Phase 1 cap) | contact_list_recipients capped at 20 000 rows | contacts_count on ContactList | CreateFromSegmentProcess hard cap | PRD Story 10.2 |
| WA-eligible customers only (phone OR BSUID) | contact_list_recipients.phone_number OR account_uniq_id populated | filter applied at CDP API call | CDP API channel=whatsapp filter | PRD Story 10.1 |
| Up to 150 customer properties as recipient variables | contact_extras.extra hash (key = property name, value = mapped value) | contact_variables on ContactList (ES) | CreateFromSegmentProcess type mapping | PRD Story 10.3 |
| CDP property type mapping to recipient variable types | stored as Text/Number/Date/URL strings in contact_extras.extra | recipient list variables in campaign API | CreateFromSegmentProcess mapping logic | PRD Story 10.5 |
| Campaign must be scheduled ≥ 1 hour in advance when segment audience | message_broadcasts.send_at >= Time.now + 1.hour | 422 response if violated | UserCreateBroadcast contract rule | PRD Story 11 |
| Balance validation with 10 % buffer | transient check against billing | Failure response before ContactList creation | UserCreateBroadcast → ValidateBroadcastQuota | PRD Story 9.2 |
recurring sending option disabled for segment audience | message_broadcasts.execute_type must not be campaign_plan | 422 if execute_type=campaign_plan + segment_id present | Contract rule in UserCreateBroadcast | PRD Story 9.3 |
| Customer list viewable in Campaign Detail | contact_list_recipients rows sorted by full_name ASC | GET /contact_lists/:id/recipients | Repositories::ContactListRecipients::List (new) | PRD Story 12 |
| Source field shown as "Segment" in Recipient List index | contact_lists.source_type = 'segment' already indexed in ES | existing list endpoint filters/displays source_type | ES mapping already has source_type | PRD Story 13 |
| BSUID used when phone number unavailable | contact_list_recipients.account_uniq_id | included in recipient row | CreateFromSegmentProcess mapping | PRD Story 14 |
| Datadog metric for audience source | no persistence | upload_contact_from_segment_status Datadog metric | CreateFromSegmentProcess | PRD Story 15 |
| Campaign itself records its audience source (not just the contact list) | message_broadcasts.source_type :string (new) | campaign list/detail response includes source_type without joining contact_lists | migration + Models::MessageBroadcast | reviewer feedback, 2026-06-30 |
| Prevent duplicate campaign for the same segment | uniqueness check across message_broadcasts.segment_id + message_template_id + status | 422 on duplicate create attempt | UserCreateBroadcast contract rule | reviewer feedback, 2026-06-30 |
| Segment ownership must be verified (IDOR) | n/a — enforced at request time, not persisted | 403/404 if segment_id does not belong to caller's organization_id | UserCreateBroadcast + Repositories::ContactListRecipients::List | reviewer feedback, 2026-06-30 |
| Track which CDP segment definition state a recipient snapshot was generated against | contact_lists.segment_version :string (new, nullable) | n/a — internal/debug field, not yet surfaced on any API response | CreateFromSegmentProcess (population pending Open Q #11) | user feedback, 2026-07-07 |
Detail 1.A — PRD Traceability Matrix
Forward (PRD → RFC):
| PRD requirement | Service / endpoint / job | RFC section |
|---|---|---|
| Create campaign with segment audience | UserCreateBroadcast (extended) | §2 Technical Decisions, §2.4 APIs |
| Async generate recipients from segment | CreateRecipientFromSegmentWorker + CreateFromSegmentProcess | §2.C Async Spec |
| 1-hour schedule window | UserCreateBroadcast contract rule | §2.4 APIs |
| Balance validation 10 % buffer | ValidateBroadcastQuota (existing, extended) | §2.4 APIs |
| CDP customer properties → recipient variables | CreateFromSegmentProcess type mapping | §2.C, §2.3 DDL |
| Campaign detail — customer list from segment | GET /contact_lists/:id/recipients | §2.4 APIs |
| BSUID fallback | CreateFromSegmentProcess mapping | §2.C |
| Datadog tracking | CaptureCustomMetric in worker | §2.C |
Reverse (RFC → PRD):
| New endpoint / table / service | PRD need it serves |
|---|---|
contact_lists.segment_id column | Async worker needs to call CDP for the right segment |
Repositories::ContactLists::CreateFromSegment | Entry point mirrors CreateDirectSelectAll for segment source |
Services::Cdp::SegmentClient | HTTP wrapper isolates CDP API from business logic |
GET /contact_lists/:id/recipients | Campaign detail drawer (Story 12) |
message_broadcasts.source_type column | Reviewer feedback: campaign list/detail needs audience source without joining contact_lists |
Duplicate-campaign contract rule (segment_id + message_template_id + status) | Reviewer feedback: prevent double-sending the same segment+template combo |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads | Required writes | Status surface |
|---|---|---|---|---|
| Campaign Create Form (Qontak One) | FE web | GET /iag/v1/segments (CDP direct) | POST /broadcasts (extended) | contact_list.progress polling |
| Campaign Detail drawer — customer list | FE web | GET /contact_lists/:id/recipients (new) | n/a — fully covered by async write | contact_list.progress |
| Recipient Lists index | FE web | existing list endpoint + source_type filter | n/a | contact_list.progress |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted | Cross-tenant? | Audit trail |
|---|---|---|---|---|
| Agent / Supervisor / Admin (Qontak One) | IAG JWT + customers_segment_view permission | POST /broadcasts (extended), GET /contact_lists/:id/recipients | no — organization_id scoped | Models::MessageBroadcast row creation |
| System (worker) | internal / no auth boundary | Worker job processes asynchronously | no | ContactList.progress transitions |
PRD Section Coverage
| PRD section | Title | RFC section |
|---|---|---|
| TL;DR / Problem Statement | Segment as campaign audience | §1 Overview |
| Phase 1 feature description | Send Campaign with Segment up to 20K | §1 Overview, §2 |
| Story 1–8 | FE UX updates | n/a — covered in FE RFC |
| Story 9 | Create Campaign with Segment as Audience | §2.4, §2.C, §4.C chunk 5 |
| Story 10 | Automatically Generate Recipients | §2.C, §2.3 DDL |
| Story 11 | Minimum Scheduling Window | §2.4, Decision 4 |
| Story 12 | Campaign Detail — Customer List | §2.4 APIs |
| Story 13 | Recipient Lists index — source field | n/a — source_type already indexed in ES; no new BE needed |
| Story 14 | BSUID fallback | §2.C |
| Story 15 | Measure Campaign Usage | §2.C (Datadog) |
| Phase 2 | Batching for >20K | n/a — deferred |
| CDP Field Mapping | Property type mapping | §2.C |
| Role Access | RBAC for segment view | §3 Security |
| Package Availability | Qontak One with CDP module | §3 Feature Flag |
| Release Dependencies | CDP team readiness | §5 Open Questions |
Detail 1.B — Key Decisions Summary
| # | Decision | Chosen option | §2 block |
|---|---|---|---|
| 1 | Storage: how to link segment to contact list | Add segment_id column to contact_lists | Decision 1 |
| 2 | Sync vs async for recipient generation | Async (Sidekiq) — mirrors CreateDirectSelectAll | Decision 2 |
| 3 | CDP integration method | Direct HTTP (new Services::Cdp::SegmentClient) | Decision 3 |
| 4 | Schedule minimum for segment campaigns | 1-hour window enforced in UserCreateBroadcast contract | Decision 4 |
| 5 | Balance validation with segment audience | Use FE-provided estimated count; validate before ContactList creation | Decision 5 |
| 6 | Reuse vs new for create entry point | New Repositories::ContactLists::CreateFromSegment (clone pattern from CreateDirectSelectAll) | Decision 6 |
| 7 | Pagination for CDP S2S endpoint | Offset-based — confirmed (20K cap makes drift risk negligible) | Decision 7 |
| 8 | Consistency model | Eventual — campaign executes if progress == 'success' OR 'partially_completed' | Decision 2 |
| 9 | Partial import failure handling | partially_completed status — campaign sends to imported contacts; failed pages skipped | Decision 9 |
| 10 | Orphan ContactList prevention | Wrap ContactList + Broadcast creation in ActiveRecord::Base.transaction; enqueue worker AFTER commit | Decision 10 |
| 11 | REOPENED 2026-06-30 — CDP identity field / contact resolution | Interim: resolve account_uniq_id by looking up Models::Contact on phone_number/email, not by trusting CDP id directly — pending CDP team confirmation | Decision 8 |
| 12 | "Send Now" sending option for segment audience | 1-hour minimum only applies to execute_type=specific; send_now bypasses the window and executes as soon as contact_list.progress reaches a terminal state | Decision 11 |
| 13 | Prevent duplicate segment campaigns | Contract rule rejects a new broadcast when an existing message_broadcasts row shares segment_id + message_template_id and is not in a terminal/failed status | Decision 12 |
| 14 | source_type on the campaign itself | Add message_broadcasts.source_type alongside the existing contact_lists.source_type | Decision 13 |
| 15 | IDOR mitigation on segment_id | Validate segment_id belongs to the caller's organization_id before creating a ContactList or listing recipients | Decision 14 |
| 16 | NEW, OPEN — segment_version snapshot marker | Add nullable contact_lists.segment_version column now (additive, zero-cost); population source pending CDP confirmation | Decision 15 |
Detail 1.C — Per-Story Change Map
| Story # | Story title | Layer scope | BE changes | Acceptance criteria | RFC anchors |
|---|---|---|---|---|---|
| 1–8 | Campaign form / detail UX updates | n/a — covered in FE RFC | n/a | n/a | n/a |
| 9 | Create Campaign with Segment as Audience | BE-only | Extend UserCreateBroadcast contract + validate_contact_list to handle segment_id path; call CreateFromSegment; validate segment ownership (IDOR, Decision 14); validate 1-hour window for specific schedules only (Decision 11); reject duplicate segment+template campaigns (Decision 12); validate balance; set message_broadcasts.source_type (Decision 13) | rspec user_create_broadcast_wa_cloud_spec.rb passes with segment_id happy path, balance failure, wrong-org segment_id (403), duplicate segment+template (422), send_now bypassing the 1-hour rule | §2.4 row 1 · §4.C chunk 5 · §1 PRD-to-Schema rows 1–5 |
| 10 | Automatically Generate Recipients | BE-only | New worker CreateRecipientFromSegmentWorker; new repos CreateFromSegment, CreateFromSegmentProcess; new service Cdp::SegmentClient; pending Decision 8: resolve account_uniq_id via Models::Contact lookup on phone_number/email rather than trusting CDP id | rspec: worker creates correct ContactListRecipient rows; progress='success' after run; caps at 20 000; contact-resolution lookup covered once Decision 8 is confirmed | §2.C · §4.C chunk 3–4 |
| 11 | Minimum Scheduling Window (1 hour) / Send Now | BE-only | Dry-validation contract rule in UserCreateBroadcast: send_at >= now + 1.hour when segment_id present AND execute_type=specific; send_now path bypasses the window (Decision 11) | rspec contract failure when send_at < now + 1.hour with segment_id + execute_type=specific; rspec happy path for send_now | §2.4 · §4.C chunk 5 |
| 12 | Show Customer List from Segment in Campaign Detail | BE-only | New GET /contact_lists/:id/recipients endpoint backed by Repositories::ContactListRecipients::List; sorted by full_name ASC; paginated | rspec: returns recipients sorted ASC; pagination works; wrong organization_id returns 404 | §2.4 row 2 · §4.C chunk 6 |
| 13 | Recipient Lists index — source field | n/a — no new BE needed | source_type already indexed in ES and returned by existing list endpoint. FE can filter/display directly. | existing spec passes | — |
| 14 | BSUID fallback in recipient generation | BE-only | CreateFromSegmentProcess: when phone array is empty, fall back to account_uniq_id from CDP response | rspec: recipient row created with account_uniq_id when phone absent | §2.C |
| 15 | Measure Campaign Usage by Audience Source | BE-only | Add Datadog metric create_campaign_audience_type with tag type:segment or type:recipient_list in UserCreateBroadcast | Datadog metric emitted on campaign creation (verify via log or test double) | §2.C |
2. Technical Design
Infrastructure Topology
Deployment topology
flowchart TB
internet([FE / Mobile Client]) -->|HTTPS| lb[Load Balancer / API Gateway]
lb -->|HTTP| api["hub-service API pods ×N\n(stateless)"]
api -->|read/write| db_primary[(Postgres primary\nchat DB)]
api -->|read-only| db_replica[(Postgres replica)]
api -->|get/set| cache[(Redis REDIS_W/REDIS_R)]
api -->|enqueue| queue[["Sidekiq queue\ncreate_recipient_from_segment"]]
queue -->|consume| worker["hub-core worker pod\nCreateRecipientFromSegmentWorker"]
worker -->|read/write| db_primary
worker -->|HTTPS S2S BasicAuth| cdp(["CDP contact-service\ncontact-service.qontak.net"])
worker -->|index| es[(Elasticsearch\nContactList index)]
api -->|index| es
Per-service responsibility
flowchart LR
subgraph hub_core["hub-core (this RFC)"]
ep1["POST /broadcasts\n(extended — segment_id path)"]
ep2["GET /contact_lists/:id/recipients\n(new)"]
w1["CreateRecipientFromSegmentWorker\n(async)"]
end
subgraph cdp_svc["CDP contact-service (external)"]
cdp1["GET /api/v1/segments/:id/customers\n(S2S BasicAuth)"]
end
ep1 -->|"creates ContactList + enqueues"| w1
w1 -->|"HTTPS BasicAuth — offset pagination"| cdp1
w1 -->|"bulk import"| db[(Postgres\ncontact_lists\ncontact_list_recipients\ncontact_extras)]
ep2 -->|"read"| db
Technical Decisions
Decision 1: Storage — link CDP segment to ContactList
Context A campaign created with a segment audience needs to reference which CDP segment produced its recipients, so the async worker knows which segment to fetch from CDP, and future queries can distinguish segment-based lists from manual uploads.
Options considered
- Option A — Add
segment_idcolumn tocontact_lists: Minimal schema change;source_type='segment'already indexed in ES.- Pros: Localized change; ES mapping already indexes
source_type; no join required. - Cons:
segment_idis a foreign identifier from an external system (CDP); no FK constraint possible.
- Pros: Localized change; ES mapping already indexes
- Option B — New
contact_list_segmentsjoin table: Richer relationship table.- Pros: Supports future many-to-many.
- Cons: Over-engineered for Phase 1; adds join to every query.
Decision: Option A — add segment_id :string to contact_lists.
Rationale: Mirrors the existing pattern of storing source_type on the same table. ES index already maps source_type. Only a single segment is ever associated with one contact list.
Consequences: segment_id is a CDP-issued string ID with no FK constraint; deletion of the segment in CDP will not cascade — acceptable since recipient generation is a snapshot. Models::ContactList#as_indexed_json must add segment_id to the ES mapping alongside the existing source_type field (reviewer confirmed this is sufficient — no separate service needed just to avoid ES, 2026-06-30).
Reversibility: Drop column + re-index ES. Low cost.
Decision 2: Sync vs async — recipient generation
Context
Fetching up to 20 000 CDP customers via a paginated API (100 per page = 200 HTTP calls minimum) would block the API response for 30–200 seconds. The existing CreateDirectSelectAll flow is already async via Sidekiq.
Options considered
- Option A — Async Sidekiq worker (mirrors
CreateDirectSelectAll):- Pros: API responds immediately; proven pattern in codebase; fits within Sidekiq retry semantics.
- Cons: Campaign must be scheduled ≥ 1 hour in advance to allow processing window.
- Option B — Synchronous API call with streaming:
- Pros: Simpler; no scheduling constraint.
- Cons: Long-running request violates gateway timeouts (~30s); blocks a Puma thread.
Decision: Option A — async Sidekiq worker.
Rationale: Direct clone of proven CreateDirectSelectAll pattern. The 1-hour scheduling window is a product decision already accepted by PM (PRD Story 11).
Consequences: Campaign must be scheduled ≥ 1 hour from creation. Eventual consistency: campaign execution must check contact_list.progress == 'success' before broadcasting. Worker retry (3×) handles CDP transient failures.
Reversibility: Switch to sync by removing the worker dispatch and inlining the CDP call — straightforward refactor if CDP response times improve dramatically.
Decision 3: CDP integration method
Context Hub-core needs to fetch segment customer data from CDP's contact-service. Three integration patterns are available.
Options considered
- Option A — Direct HTTP via new
Services::Cdp::SegmentClient(Faraday/Net::HTTP):- Pros: Simple; no additional infrastructure; credentials managed as env vars.
- Cons: Requires timeout + retry configuration; no circuit breaker out of the box.
- Option B — Via existing internal event bus (Kafka/RabbitMQ):
- Pros: Decoupled; natural for large data volumes.
- Cons: CDP team does not publish segment members to Kafka; would require CDP-side work; out of scope.
- Option C — CDP SDK (if one exists):
- Pros: Vendor-managed retry/auth.
- Cons: No Ruby SDK exists for CDP contact-service.
Decision: Option A — new Services::Cdp::SegmentClient.
Rationale: CDP only exposes a REST API; Kafka integration would require CDP-side work. Direct HTTP is the only practical option for Phase 1. The service isolates HTTP concerns from the orchestrator.
Consequences: Must implement timeout (30s per page), retry at HTTP layer (3× with exponential backoff), and connection pooling (persistent HTTP via Faraday). Error from CDP maps to Sidekiq retry.
Reversibility: Replace with SDK or event-driven approach without changing the orchestrator — CreateFromSegmentProcess calls through the service interface.
Decision 4: Schedule minimum enforcement point
Context A segment campaign needs at least 1 hour of lead time for async recipient generation. Where to enforce this?
Options considered
- Option A — Enforce in
UserCreateBroadcastdry-validation contract:- Pros: Fails fast before any DB write; consistent with existing
send_atvalidation pattern (L82–87 ofuser_create_broadcast.rb); returns 422 to FE. - Cons: Contract grows larger.
- Pros: Fails fast before any DB write; consistent with existing
- Option B — Enforce in
CreateFromSegmentrepository:- Pros: Closer to the segment path.
- Cons: A ContactList record might be created before the check; harder to rollback.
Decision: Option A — dry-validation contract rule in UserCreateBroadcast.
Rationale: Existing rule(:send_at) pattern is at lines 54–87 of app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb. New rule added alongside: when segment_id present and execute_type is specific, send_at must be >= Time.now + 1.hour.
Consequences: FE must show the correct time picker constraints (1-hour minimum). BE returns 422 with human-readable message if violated.
Reversibility: Remove the rule — trivial.
Decision 5: Balance validation with segment audience
Context
The existing ValidateBroadcastQuota path at UserCreateBroadcast:L230 uses contact_list_id to compute cost. For segment campaigns the contact list is created after validation, so the real count is not yet available.
Options considered
- Option A — FE passes
estimated_recipient_count; BE uses it for validation:- Pros: No extra CDP API call from hub-core at create time; fast.
- Cons: FE-provided count could differ from actual count. Accepted per PRD (10% buffer covers minor variance).
- Option B — Hub-core calls CDP
GET /segments/:idto fetchreachability.whatsapp.countbefore validation:- Pros: Count from authoritative source.
- Cons: Adds synchronous external API call to the critical path; increases latency; may fail if CDP is unavailable.
Decision: Option A — trust FE-provided estimated_recipient_count with 10% buffer already applied.
Rationale: The 10% buffer in the balance formula (required = total_cost + 10%) absorbs minor variance between estimated and actual counts. PRD Story 9.2 explicitly describes the formula using estimated_recipients. CDP unavailability should not block campaign creation.
Consequences: In edge cases, actual recipient count may exceed estimate, so the campaign may have a slight balance overage — acceptable per PRD.
Reversibility: Replace with Option B by adding a Repositories::Cdp::FetchSegmentReachability call in the contract — minimal change.
Decision 6: Reuse vs new — entry point repository
Context
CreateDirectSelectAll is the existing sync entry point that creates a ContactList + enqueues a worker. The segment path needs the same pattern but with different parameters.
Options considered
- Option A — New
Repositories::ContactLists::CreateFromSegment(clone pattern):- Pros: Clean separation; segment-specific logic isolated;
CreateDirectSelectAllunchanged. - Cons: Some duplication of the create-ContactList + enqueue pattern.
- Pros: Clean separation; segment-specific logic isolated;
- Option B — Extend
CreateDirectSelectAllwithsource_typebranching:- Pros: Less code.
- Cons: Mixes two different data flows; harder to test in isolation; violates SRP.
Decision: Option A — new Repositories::ContactLists::CreateFromSegment.
Rationale: The existing codebase pattern for different audience sources is one repository per source (e.g., CreateDirectSelectAll, CreateViaUpload). A new repository keeps the segment path isolated and independently testable.
Consequences: Three new files to create (repository, worker, process). Slightly more code surface but far more testable.
Reversibility: Merge back into a shared base class if patterns converge — straightforward refactor.
Decision 7: CDP pagination protocol
Context
Services::Cdp::SegmentClient must fetch customers page by page. The async 1-pager described cursor-based pagination; the official CDP API doc shows offset-based.
Decision: Offset-based (page / per_page max 100).
Rationale: Confirmed by product decision — Phase 1 cap of 20,000 recipients = max 200 pages. At that scale, offset-drift risk (segment membership changing mid-fetch) is negligible since recipient generation is an explicit snapshot.
Consequences: fetch_customers uses ?page=N&per_page=100&channel=whatsapp. Loop terminates when page > total_pages or total_created >= MAX_RECIPIENTS.
Reversibility: Switch to cursor by changing SegmentClient.fetch_customers signature and the loop in CreateFromSegmentProcess — no schema change needed.
Decision 8: CDP identity field mapping — REOPENED 2026-06-30
Context
Recipient rows need account_uniq_id (BSUID) or phone_number populated to be broadcastable. The 2026-06-22 close of Open Q #6 assumed the CDP customer id (UUID) in the segment-members response could be written directly to contact_list_recipients.account_uniq_id.
New information (reviewer feedback, Jovi Renaldo / Isna Rahmatul Khoir, 2026-06-30): CDP's segmentation storage only carries phone and email per customer — it does not store a hub_core contact_id or account_uniq_id. If that is accurate, the id field in the CDP response cannot be treated as a BSUID. Writing it into account_uniq_id as-is would create recipient rows that don't map to any real hub_core contact, silently breaking BSUID-based delivery.
Options considered
- Option A — CDP
idgenuinely is (or maps 1:1 to) the hub_core BSUID: no code change needed beyond re-confirming with the CDP team. Cheapest if true, but unverified as of this feedback. - Option B — Resolve identity via lookup: for each CDP customer, look up
Models::Contact(org-scoped) byphone_numberfirst, thenemail, and use the matched contact's ownaccount_uniq_idfor the recipient row. If no match, fall back tophone_numberonly (still broadcastable via phone; not eligible for BSUID-only delivery).
Decision: Reopened — no final decision yet. Implement defensively per Option B until the CDP team confirms the exact response shape (dependency added — see §Dependencies). This changes CreateFromSegmentProcess from a pure map/transform step into a step that also queries Models::Contact.
Consequences if Option B is required:
- Batch the lookup per page (
WHERE organization_id = ? AND (phone_number IN (...) OR email IN (...))) to avoid N+1 queries — one query per 100-row page, not per customer. Repositories::ContactLists::AddContactsModelToContactListv2 branch may need a new variant that accepts pre-resolvedaccount_uniq_idalongside CDP-sourcedphone_number/custom fields, instead of assuming CDP already supplies it.- Customers present in the CDP segment but with no matching
Models::Contactrow are NOT excluded — they still get acontact_list_recipientsrow keyed onphone_number(oremail, pending Story-9-adjacent PRD confirmation on email-channel eligibility); they simply have noaccount_uniq_id. - Chunk 4 (
CreateFromSegmentProcess) acceptance criteria in §4.C must gain a case for "CDP customer has no matching internal contact."
Reversibility: If CDP later confirms Option A, drop the lookup and revert to a pure field copy — the recipient bulk-import call shape doesn't change, only how account_uniq_id is sourced.
Decision 9: Partial import failure handling — partially_completed status
Context
When CreateFromSegmentProcess fetches CDP pages, individual pages may fail (timeouts, 5xx). The original design treated any page failure as a full retry. This blocked campaigns even when 90%+ of contacts were successfully imported.
Options considered
- Option A — Full retry on any page failure (original): All retries fail →
progress='failure'→ campaign blocked.- Pros: Simple; consistent data.
- Cons: Campaign blocked even when most contacts imported; poor UX.
- Option B — Per-page error handling with
partially_completedstatus (chosen):- Pros: Campaign sends to successfully imported contacts; visible partial count; user informed.
- Cons: Non-uniform recipient set — some pages' contacts missing.
Decision: Option B — per-page error handling. Introduce partially_completed progress status.
Logic:
- Each CDP page fetch is wrapped in its own rescue block.
- Failed pages are logged and skipped; processing continues.
- At end of loop:
total_created > 0ANDfailed_pages.any?→progress = 'partially_completed'total_created > 0AND no failures →progress = 'success'total_created == 0→ raise (Sidekiq retries; after 3 retries →progress = 'failure')
- Campaign execution check updated: proceed if
progress IN ('success', 'partially_completed').
Consequences:
Models::ContactListenum gainspartially_completed: 'partially_completed'.BroadcastSpecificWorker(wa_cloud) must update its progress check to include'partially_completed'.contact_lists.error_messagesstores the list of failed page numbers for observability.
Reversibility: Remove per-page rescue and restore raise-on-error — trivial code change.
Decision 10: Orphan ContactList prevention — transaction wrapping
Context
UserCreateBroadcast creates a ContactList and then a MessageBroadcast in two separate writes. If Broadcast creation fails, the ContactList remains in processing state indefinitely (worker fires, marks success, but no Broadcast references it).
Decision: Wrap ContactList creation and Broadcast creation in ActiveRecord::Base.transaction. Enqueue the worker after the transaction commits (not inside it).
Implementation:
# In UserCreateBroadcast#result (segment path):
contact_list = nil
ActiveRecord::Base.transaction do
result = yield Repositories::ContactLists::CreateFromSegment.new(
params: attrs, skip_enqueue: true # creates ContactList only, no enqueue
).call
contact_list = result
yield Repositories::Whatsapp::Broadcasts::Create.new(
params: attrs.merge(contact_list_id: contact_list.id)
).call
end
# Enqueue only after both writes committed
CreateRecipientFromSegmentWorker.perform_async(
contact_list_id: contact_list.id,
segment_id: attrs[:segment_id],
organization_id: attrs[:organization_id],
segment_name: attrs[:segment_name]
)
Success(broadcast)
Rationale: Atomic ContactList + Broadcast creation eliminates orphan risk. Worker enqueue outside transaction avoids the "enqueue-before-commit" anti-pattern (job fires before row is visible).
Consequences: CreateFromSegment gains a skip_enqueue: option (default false, for backward compatibility). When skip_enqueue: true, it creates the ContactList record and returns it without calling perform_async.
Reversibility: Remove skip_enqueue option and restore direct perform_async inside CreateFromSegment — trivial.
Reviewer flagged this decision as "need to review later" (Isna Rahmatul Khoir, 2026-06-30) without further detail. No specific objection was raised — flagged here for a follow-up pass before Chunk 5 is finalized, not treated as a blocking open question.
Decision 11: "Send Now" sending option for segment audience
Context
The original design assumed every segment campaign is scheduled (execute_type=specific) and enforced the 1-hour minimum window on all of them. Reviewer feedback (Isna Rahmatul Khoir, 2026-06-30) pointed out that "send now" is a real sending option users expect, and asked how it interacts with the async recipient-generation delay — a campaign can't literally send at the instant it's created if contact_list.progress is still processing.
Options considered
- Option A — Disable
send_nowentirely for segment audience: simplest, but contradicts the sending-option matrix reviewers expect (send now / send later / recurring-disabled) and PRD Story 9's option list. - Option B —
send_nowfires as soon as recipient generation reaches a terminal state, with no additional user-set delay: the 1-hour minimum only ever applied toexecute_type=specific(explicit user-picked schedule time);send_nowwas never subject to it — it's a differentexecute_typevalue, not a schedule value to validate against+1.hour.
Decision: Option B. send_now (execute_type value TBD-confirm with FE, e.g. immediate) skips the 1-hour contract rule entirely. BroadcastSpecificWorker's existing progress gate (progress IN ('success', 'partially_completed'), Decision 9) is what actually delays dispatch until recipients are ready — no new gating mechanism needed, but the trigger path for send_now must enqueue/attempt execution promptly rather than waiting for a scheduled cron tick, then rely on the existing progress check to hold until the worker finishes.
Consequences:
- Contract rule from Decision 4 narrows to
if values[:execute_type] == 'specific'only. send_now+ segment audience means the user sees the campaign as "sending" while recipient generation is still in flight — this must be surfaced clearly in the FE (owned by the FE RFC, out of scope here), but the BE contract for it is: campaign row exists immediately, dispatch is gated oncontact_list.progress.- Sending-option matrix (reviewer feedback, 2026-06-30):
send_now→ direct send once ready;send_later(specific) → minimum 1 hour (this is a change from the existing non-segment minimum of 30 minutes, segment-only);recurring(campaign_plan) → disabled (Decision 4, unchanged).
Reversibility: Re-add the 1-hour rule to send_now by removing the execute_type == 'specific' guard — trivial, but blocks the feature reviewers explicitly asked for.
Decision 12: Prevent duplicate segment campaigns
Context Reviewer feedback (2026-06-30): "prevent double broadcast by segment_id, template, and status." Without a check, a double form-submit or a retried FE request could create two campaigns targeting the same segment with the same template.
Decision: Add a contract rule to UserCreateBroadcast — when segment_id is present, reject the request if an existing message_broadcasts row already has the same segment_id + message_template_id and is in a non-terminal or non-failed status (i.e., pending/processing/scheduled — exact enum values TBD from Models::MessageBroadcast).
Implementation sketch:
rule(:segment_id, :message_template_id) do
if values[:segment_id].present?
duplicate = Models::MessageBroadcast
.where(organization_id: values[:organization_id], segment_id: values[:segment_id],
message_template_id: values[:message_template_id])
.where.not(status: Models::MessageBroadcast::TERMINAL_STATUSES) # TBD — confirm actual enum
.exists?
key(:segment_id).failure('a campaign for this segment and template is already in progress') if duplicate
end
end
Consequences: Requires message_broadcasts.segment_id to be readable (see Decision 13 — added alongside source_type) since the check filters by it directly rather than joining through contact_lists. Needs confirmation of Models::MessageBroadcast's actual status enum/terminal states before implementation.
Reversibility: Remove the rule — trivial.
Decision 13: source_type on message_broadcasts
Context
contact_lists.source_type already distinguishes 'contacts' vs 'segment', but reviewer feedback (2026-06-30) asked for the same signal on the campaign row itself so campaign list/detail views (and the duplicate check in Decision 12) don't need to join through contact_lists.
Decision: Add message_broadcasts.source_type :string (nullable, mirrors contact_lists.source_type values) and message_broadcasts.segment_id :string (nullable), set at creation time in UserCreateBroadcast alongside the existing ContactList creation.
Consequences: New migration column (see §2.3 DDL) additive to message_broadcasts; existing rows get NULL/'contacts' and are unaffected.
Reversibility: Drop columns — low cost, no dependents outside this feature.
Decision 14: IDOR mitigation on segment_id
Context
Reviewer feedback (Isna Rahmatul Khoir, 2026-06-30): "Handle IDOR using organization_id when requesting the list and detail, so a hardcoded segment id from another org can't be used." segment_id is a caller-supplied, CDP-issued string with no hub_core FK — nothing currently stops a caller from passing another organization's segment_id.
Decision: Before CreateFromSegment creates a ContactList, verify the segment belongs to the caller's organization_id. Mechanism depends on what CDP exposes — pending confirmation (Dependency added in §Dependencies): either (a) CDP's segment-detail endpoint accepts/returns an org identifier we can compare, or (b) hub-core must pass organization_id to CDP on every segment call and trust CDP's own tenant scoping (no separate hub-core-side check possible). GET /contact_lists/:id/recipients already scopes by organization_id via the contact_list row (no additional change needed there — the segment_id itself is never accepted as a request param on that endpoint).
Consequences: If CDP does not support server-side org scoping without hub-core echoing back organization_id, hub-core must always send organization_id on the CDP call and treat a CDP 403/empty-result as "not this org's segment" → fail campaign creation with a generic 404-style error (do not leak segment existence across orgs).
Reversibility: n/a — this is a security control, not reversible without reintroducing the IDOR gap.
Decision 15: segment_version — CDP segment definition snapshot marker — NEW, OPEN 2026-07-07
Context Per Assumption 4, CDP segment membership is treated as an immutable snapshot at recipient-creation time — segment changes between submit and send are explicitly out of scope for execution behavior. User feedback (2026-07-07) asked for a way to record which state of the segment definition a given recipient snapshot corresponds to, so a stale/drifted snapshot can be identified later (support/debugging, and a possible future "resync" feature).
Options considered
- Option A — Store a CDP-provided segment version/updated-at marker: most accurate, ties directly to CDP's source of truth for what changed.
- Cons: Not confirmed to exist. The only CDP fields currently documented in this RFC (§Detail 2.0 source verification) are from the
/customersendpoint (name,phone,custom_fields,added_at) — no segment-level version/updated-at field has been verified.
- Cons: Not confirmed to exist. The only CDP fields currently documented in this RFC (§Detail 2.0 source verification) are from the
- Option B — Store hub-core's own snapshot timestamp:
contact_lists.created_atalready exists and trivially answers "when did hub-core fetch this."- Cons: Doesn't answer whether CDP's segment definition changed since — only proves when we looked, not what changed.
Decision: Open — not yet confirmed. Add a nullable contact_lists.segment_version :string column now (additive, reversible, zero behavioral impact while unpopulated) so Chunk 1 isn't blocked. Do not implement population logic in CreateFromSegmentProcess (Chunk 4) until Open Q #11 is resolved.
Consequences: Column stays NULL for all rows until Chunk 4's identity-resolution work (already gated on Open Q #7) also confirms whether CDP exposes a usable segment-version signal. If Option A is confirmed, CreateFromSegmentProcess sets it per fetch; if not, fall back to Option B using the existing created_at.
Reversibility: Drop column — no dependents, low cost.
Detail 2.0 — Repo Reading Guide
Repo Map (mermaid)
flowchart LR
subgraph hub_core_app["app/core/domains/"]
subgraph repos["repositories/contact_lists/"]
CDA["create_direct_select_all.rb\n(existing — pattern ref)"]
CDAP["create_direct_select_all_process.rb\n(existing — pattern ref)"]
CFS["create_from_segment.rb\n(NEW)"]
CFSP["create_from_segment_process.rb\n(NEW)"]
ACMCL["add_contacts_model_to_contact_list.rb\n(existing — reuse version 2 path)"]
end
subgraph models_dir["models/"]
CL["contact_list.rb"]
CLR["contact_list_recipient.rb"]
end
subgraph interactors_dir["interactors/whatsapp/broadcasts/"]
UCB["user_create_broadcast.rb\n(extend)"]
end
end
subgraph workers["app/core/workers/"]
CDAW["create_contact_list_direct_select_all_worker.rb\n(existing — pattern ref)"]
CRSW["create_recipient_from_segment_worker.rb\n(NEW)"]
end
subgraph services["app/apps/broadcast_service/services/"]
CDP_SVC["cdp/segment_client.rb\n(NEW)"]
end
subgraph migrations["database/core/db/migrate/"]
MIG["YYYYMMDD_add_segment_id_to_contact_lists.rb\n(NEW)"]
end
CFS --> CRSW
CRSW --> CFSP
CFSP --> CDP_SVC
CFSP --> ACMCL
UCB --> CFS
Existing Code Anchors
| Path | Why the agent reads it | What pattern it teaches |
|---|---|---|
app/core/domains/repositories/contact_lists/create_direct_select_all.rb | Template for CreateFromSegment | Creates ContactList record + enqueues Sidekiq worker + Datadog metric; sets source_type, progress, version |
app/core/workers/create_contact_list_direct_select_all_worker.rb | Template for new worker | AbstractSidekiqWorker; sidekiq_options queue:, retry:; delegates to process repo |
app/core/domains/repositories/contact_lists/create_direct_select_all_process.rb | Template for CreateFromSegmentProcess | Pagination loop; contact_list.progress transitions; ES index; Datadog |
app/core/domains/repositories/contact_lists/add_contacts_model_to_contact_list.rb | Reuse version 2 path for bulk import | @contact_list.version == '2' branch; ContactListRecipient bulk import via activerecord-import; ContactExtra creation |
app/core/domains/models/contact_list.rb | Understand existing schema + ES mapping | source_type already a column (L37 as_indexed_json); progress enum (L14–18); version column; contacts_count uses ContactListRecipient for v2 |
app/core/domains/models/contact_list_recipient.rb | Understand recipient row structure | belongs_to ContactList; has_many ContactExtras |
app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb | Extend for segment path | contract block (L8–123); result method (L126–153); validate_contact_list (L198–207); validate_broadcast_quota (L230–232) |
database/core/db/migrate/20250505151110_add_version_to_contact_lists.rb | Migration dialect pattern | ActiveRecord::Migration[6.1]; column_exists? guard; add_column |
app/core/domains/models/contact.rb (agent: confirm exact path/name) | Decision 8 — needed if CDP id is not usable as BSUID; agent must locate the model that indexes contacts by phone_number/email for org-scoped lookup | schema/columns available for the batched WHERE organization_id = ? AND (phone_number IN (...) OR email IN (...)) lookup |
app/core/domains/models/message_broadcast.rb (agent: confirm exact path/name) | Decisions 12–13 — needed for the duplicate-campaign check and for adding source_type/segment_id columns | existing status enum/terminal states; existing columns to extend |
Existing Contracts to Reuse, Extend, or Replace
| Contract | Status | Justification | Owner |
|---|---|---|---|
POST /broadcasts (UserCreateBroadcast) | extended | Add optional segment_id + estimated_recipient_count params + 1-hour schedule rule | Chat-2 |
GET /contact_lists/:id/recipients | new-with-justification | No existing endpoint returns per-recipient rows for a contact list; needed for Campaign Detail drawer (Story 12) | Chat-2 |
GET /api/v1/segments/:segment_id/customers (CDP) | reused | Existing CDP S2S endpoint from Technical Design API doc | CDP team |
Patterns to Follow
| Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|
| Sync create + async worker entry point | Repositories::ContactLists::CreateDirectSelectAll | create_direct_select_all.rb:3 | none |
| Sidekiq worker shape | CreateContactListDirectSelectAllWorker | create_contact_list_direct_select_all_worker.rb:3 | none |
| Pagination + bulk import orchestrator | Repositories::ContactLists::CreateDirectSelectAllProcess | create_direct_select_all_process.rb:3 | yes — data source is CDP API, not ES |
| Version 2 bulk import (ContactListRecipient) | AddContactsModelToContactList version 2 branch | add_contacts_model_to_contact_list.rb:31 | yes — input is CDP response, not contact_id array |
| Dry-validation contract extension | UserCreateBroadcast contract block | user_create_broadcast.rb:8 | new rule(:segment_id) added |
| Datadog metric | Services::Datadog::CaptureCustomMetric | create_direct_select_all_process.rb:58 | none — same service, new metric name |
| Feature flag check | Services::Preference.new.enabled?(:flag) | create_direct_select_all.rb:22 | none |
| Migration format | AddVersionToContactLists | 20250505151110_add_version_to_contact_lists.rb:3 | none |
Reading Order for the Agent
app/core/domains/repositories/contact_lists/create_direct_select_all.rb— understand the sync entry point pattern to clone.app/core/workers/create_contact_list_direct_select_all_worker.rb— understand worker boilerplate.app/core/domains/repositories/contact_lists/create_direct_select_all_process.rb— understand the orchestrator loop to adapt.app/core/domains/repositories/contact_lists/add_contacts_model_to_contact_list.rb— read version 2 branch (lines 31–60) to understand bulk import.app/core/domains/models/contact_list.rb— confirm existing columns (source_type,version,progress) and ES mapping.app/core/domains/models/contact_list_recipient.rb— confirm recipient row columns.app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb— read full file to understand where to add segment path.database/core/db/migrate/20250505151110_add_version_to_contact_lists.rb— migration format.database/core/db/migrate/20260504100000_add_ext_bsuid_columns_to_contact_list_recipients.rb— bsuid column reference.
Source Verification (anti-hallucination)
| Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|
create_direct_select_all.rb | read | source_type: 'contacts' at L19; CreateContactListDirectSelectAllWorker.perform_async at L30; Builders::ContactList.new(contact_list).build at L34 |
create_contact_list_direct_select_all_worker.rb | read | sidekiq_options queue: :create_contact_list_direct_select_all, retry: 3 at L4; delegates to CreateDirectSelectAllProcess at L7 |
create_direct_select_all_process.rb | read | loop do pagination at L19; contact_list.progress = 'success' at L47; .__elasticsearch__.index_document at L56; CaptureCustomMetric at L58 |
add_contacts_model_to_contact_list.rb | read | Version 2 branch at L31: build_contact_list_recipients; Models::ContactListRecipient bulk import at L32 |
Models::ContactList — source_type exists | read | L37 as_indexed_json includes :source_type; ES mapping indexes it at L102 |
Models::ContactList — progress enum | read | L14–18: enum progress: { processing: 'processing', success: 'success', failure: 'failure' } |
Models::ContactListRecipient | read | L3: class Models::ContactListRecipient < Models::AbstractModel; L6: has_many :contact_extras |
UserCreateBroadcast — contract + result | read | contract params block at L9–52; validate_contact_list at L198; validate_broadcast_quota at L230 |
| Migration pattern | read | AddVersionToContactLists uses column_exists? guard + add_column at L6–7 |
| CDP S2S endpoint | CDP API doc read | GET /api/v1/segments/:segment_id/customers BasicAuth; offset pagination page/per_page max 100; response has name, phone: [], custom_fields: [], added_at |
Detail 2.1 — Architecture (mermaid)
Component diagram
flowchart TB
fe([FE / API caller]) -->|POST /broadcasts + segment_id| UCB["Interactors::Whatsapp::Broadcasts\n::UserCreateBroadcast (extended)"]
UCB -->|validate balance| VBQ["Repositories::V2::Billings\n::BulkValidateBroadcastQuota (existing)"]
UCB -->|creates ContactList + enqueues| CFS["Repositories::ContactLists\n::CreateFromSegment (NEW)"]
UCB -->|creates broadcast| CB["Repositories::Whatsapp::Broadcasts\n::Create (existing)"]
CFS -->|perform_async| CRSW["CreateRecipientFromSegmentWorker (NEW)"]
CRSW --> CFSP["Repositories::ContactLists\n::CreateFromSegmentProcess (NEW)"]
CFSP -->|HTTP GET paginated| CDP_SVC["Services::Cdp::SegmentClient (NEW)"]
CDP_SVC -->|HTTPS BasicAuth| cdp(["CDP contact-service\n/api/v1/segments/:id/customers"])
CFSP -->|bulk import| ACMCL["Repositories::ContactLists\n::AddContactsModelToContactList (existing v2)"]
ACMCL -->|INSERT| recipients[(contact_list_recipients\n+ contact_extras)]
CFSP -->|UPDATE| cl[(contact_lists\nprogress = success/failure)]
CFSP -->|index| es[(Elasticsearch\nContactList index)]
fe2([FE / Campaign Detail]) -->|GET /contact_lists/:id/recipients| RLIST["Repositories::ContactListRecipients\n::List (NEW)"]
RLIST -->|SELECT ORDER BY full_name ASC| recipients
Service use cases & third-party connections
flowchart LR
subgraph hub_core["hub-core"]
uc1["Extend UserCreateBroadcast\n(POST /broadcasts — segment path)"]
uc2["CreateRecipientFromSegmentWorker\n(async — segment recipient generation)"]
uc3["GET /contact_lists/:id/recipients\n(Campaign Detail drawer)"]
end
uc1 -->|"in-process"| billing(["BulkValidateBroadcastQuota\n(existing)"])
uc1 -->|"async enqueue"| uc2
uc2 -->|"HTTPS BasicAuth\np99 TBD — see Open Q #2"| cdp(["CDP contact-service\ncontact-service.qontak.net"])
uc3 -->|"DB read"| db[(Postgres)]
Data model (mermaid erDiagram)
erDiagram
CONTACT_LISTS ||--o{ CONTACT_LIST_RECIPIENTS : has
CONTACT_LIST_RECIPIENTS ||--o{ CONTACT_EXTRAS : has
CONTACT_LISTS {
string id PK
string organization_id
string name
string source_type "existing: contacts | segment (new value)"
string segment_id "NEW column — CDP segment UUID"
string segment_version "NEW column — snapshot marker, nullable, source TBD (Decision 15)"
string progress "processing | success | failure"
string version "2 for segment lists"
string extra_keys "JSON array of variable header names"
timestamp finished_at
text error_messages
timestamp created_at
timestamp updated_at
}
CONTACT_LIST_RECIPIENTS {
string id PK
string organization_id
string contact_list_id FK
string full_name
string phone_number
string account_uniq_id "BSUID fallback"
string status "success | failed"
timestamp created_at
timestamp updated_at
}
CONTACT_EXTRAS {
string id PK
string organization_id
string contact_list_id FK
string contact_list_recipient_id FK
jsonb extra "{ property_name: value, ... }"
timestamp created_at
timestamp updated_at
}
State machine — ContactList.progress
stateDiagram-v2
[*] --> processing : CreateFromSegment.call
processing --> success : worker completes (all pages fetched + imported, 0 failures)
processing --> partially_completed : some pages fetched + imported, some pages failed
processing --> failure : 0 eligible customers imported after all Sidekiq retries
success --> [*]
partially_completed --> [*]
failure --> [*]
note right of partially_completed
Campaign executes with available contacts.
error_messages stores failed page numbers.
end note
note right of failure
Campaign execution blocked — no contacts available.
end note
Branch & skip flow — segment audience validation
flowchart TD
submit(["POST /broadcasts\n(segment_id present)"]) --> idor_check{segment_id belongs to\ncaller's organization_id?\n(Decision 14)}
idor_check -- no --> idor_fail["404 — segment not found\n(do not reveal cross-org existence)"]
idor_check -- yes --> dup_check{Existing non-terminal\nmessage_broadcasts row for\nsame segment_id + template?\n(Decision 12)}
dup_check -- yes --> dup_fail["422 — campaign already in progress\nfor this segment + template"]
dup_check -- no --> balance_check{Balance sufficient?}
balance_check -- no --> balance_fail["422 — balance insufficient\nDo NOT create ContactList"]
balance_check -- yes --> sending_option{sending option?}
sending_option -- send_now --> recurring_check
sending_option -- send_later --> schedule_check{send_at >= now + 1h?\n(segment-only minimum;\nnon-segment stays 30 min)}
schedule_check -- no --> schedule_fail["422 — must schedule ≥ 1 hour"]
schedule_check -- yes --> recurring_check{execute_type == campaign_plan?}
recurring_check -- yes --> recurring_fail["422 — recurring not allowed with segment"]
recurring_check -- no --> create_cl["Create ContactList\n(progress: processing)\nsets message_broadcasts.source_type/segment_id\n(Decision 13)"]
create_cl --> enqueue["Enqueue CreateRecipientFromSegmentWorker"]
enqueue --> create_broadcast["Create MessageBroadcast\n(contact_list_id: contact_list.id)"]
create_broadcast --> done(["200 Success\nsend_now: dispatch gated on\ncontact_list.progress (Decision 11)"])
Detail 2.2 — Sequence Diagrams
Happy path — campaign creation with segment audience
sequenceDiagram
actor FE as FE (Qontak One)
participant LB as Load Balancer
participant API as hub-service API pod
participant UCB as UserCreateBroadcast
participant Billing as BulkValidateBroadcastQuota
participant CFS as CreateFromSegment
participant DB_W as Postgres primary
participant Q as Sidekiq queue
participant Worker as CreateRecipientFromSegmentWorker
participant CFSP as CreateFromSegmentProcess
participant CDP as CDP contact-service
FE->>LB: POST /broadcasts (segment_id, estimated_recipient_count, send_at, execute_type, ...)
LB->>API: HTTP
API->>UCB: result(params)
UCB->>UCB: verify segment_id belongs to organization_id (Decision 14 — IDOR)
UCB->>UCB: reject if duplicate non-terminal segment+template campaign exists (Decision 12)
UCB->>UCB: dry-validation contract (schedule ≥ +1h when execute_type=specific; send_now bypasses; no recurring — Decision 11)
UCB->>Billing: validate quota (estimated_recipient_count × cost + 10%)
Billing-->>UCB: Success
UCB->>CFS: call (segment_id, segment_name, org_id)
CFS->>DB_W: INSERT contact_lists (source_type: 'segment', progress: 'processing', segment_id: ...)
DB_W-->>CFS: contact_list record
CFS->>Q: CreateRecipientFromSegmentWorker.perform_async(params)
CFS-->>UCB: Success(contact_list entity)
UCB->>DB_W: INSERT message_broadcasts (contact_list_id: contact_list.id, send_at: ..., source_type: 'segment', segment_id: ... — Decision 13)
DB_W-->>UCB: broadcast record
UCB-->>API: Success(broadcast entity)
API-->>FE: 200 { data: { id: broadcast.id, ... } }
Note over FE,API: FE redirects to campaign index immediately — recipient generation is still in flight (see "Async UX contract" below)
Note over Q,Worker: async — worker picks up within seconds
Worker->>CFSP: call(contact_list_id, segment_id, org_id)
loop Offset pagination (page 1..N, per_page: 100, max 20 000)
CFSP->>CDP: GET /api/v1/segments/:id/customers?page=N&per_page=100&channel=whatsapp
Note right of CDP: p99 TBD — see Open Q #2
CDP-->>CFSP: { data: [...customers], pagination: { total, total_pages } }
Note over CFSP: pending Decision 8 — resolve account_uniq_id via\nbatched Models::Contact lookup on phone/email\n(NOT a direct copy of CDP `id`)
CFSP->>DB_W: bulk INSERT contact_list_recipients + contact_extras
DB_W-->>CFSP: OK
end
CFSP->>DB_W: UPDATE contact_lists SET progress='success', finished_at=now
CFSP->>DB_W: UPDATE contact_lists SET extra_keys=[property names]
CFSP->>ES: index ContactList document (refresh: true)
CFSP->>Datadog: upload_contact_from_segment_status tag:success
Failure path — CDP API timeout / all retries exhausted
sequenceDiagram
participant Worker as CreateRecipientFromSegmentWorker
participant CFSP as CreateFromSegmentProcess
participant CDP as CDP contact-service
participant DB_W as Postgres primary
Worker->>CFSP: call(...)
CFSP->>CDP: GET /api/v1/segments/:id/customers?page=1
Note right of CDP: timeout after 30s (3 retries via Faraday)
CDP--xCFSP: Net::ReadTimeout
CFSP->>DB_W: UPDATE contact_lists SET progress='failure', error_messages={...}
CFSP->>Datadog: upload_contact_from_segment_status tag:failure
Worker-->>Q: raise → Sidekiq retry (attempt 1/3)
Note over Worker,Q: After 3 Sidekiq retries: job moved to dead queue
Happy path — Campaign Detail recipient list
sequenceDiagram
actor FE as FE (Campaign Detail)
participant API as hub-service API pod
participant RLIST as Repositories::ContactListRecipients::List
participant DB_R as Postgres replica
FE->>API: GET /contact_lists/:id/recipients?page=1&per_page=10
API->>RLIST: call(contact_list_id:, organization_id:, page:, per_page:)
RLIST->>DB_R: SELECT * FROM contact_list_recipients WHERE contact_list_id=? AND organization_id=? ORDER BY full_name ASC LIMIT ? OFFSET ?
DB_R-->>RLIST: rows
RLIST-->>API: Success({ recipients: [...], total: N })
API-->>FE: 200 { data: [...], pagination: { total, page, per_page } }
Detail 2.2.A — Async Recipient Generation: UX Contract
Reviewer feedback (2026-06-30) asked for an explicit walk-through of what the user sees while recipient generation runs in the background, and what happens if it fails. This is the contract the BE guarantees; the FE RFC owns how it's rendered.
POST /broadcastsreturns200as soon as theContactList+MessageBroadcastrows are committed (Decision 10) — this happens before a single CDP page has been fetched. The FE is expected to redirect to the campaign index immediately; it must not block on recipient generation.- While generation runs,
contact_list.progress == 'processing'. The campaign is visible in the index/detail views, but the recipient list (GET /contact_lists/:id/recipients) may return few or zero rows until pages are imported — this is expected, not an error. - On success or partial success (
progress→successorpartially_completed, Decision 9), the recipient list is complete (or complete-minus-failed-pages) and campaign execution proceeds atsend_at(or immediately forsend_now, Decision 11). - On failure (
progress == 'failure', zero contacts imported after 3 Sidekiq retries): the campaign row still exists — it is not deleted — butBroadcastSpecificWorkerskips execution becauseprogressis not in('success', 'partially_completed'). The campaign surfaces as failed/blocked in the FE via the existingcontact_list.progressfield; no separate failure notification channel is introduced in Phase 1. Whether the user needs an explicit push notification (vs. polling the campaign detail page) is FE scope. - There is no user-facing retry action in Phase 1 — a fully failed segment campaign must be recreated from scratch (new
POST /broadcastscall); Phase 1 does not add a "retry recipient generation" endpoint.
Detail 2.3 — Database Model (DDL)
-- Migration: database/core/db/migrate/YYYYMMDDHHMMSS_add_segment_id_to_contact_lists.rb
-- (ActiveRecord::Migration[6.1] format — see existing: 20250505151110_add_version_to_contact_lists.rb)
-- Add segment_id to contact_lists
ALTER TABLE contact_lists
ADD COLUMN segment_id VARCHAR NULL;
CREATE INDEX idx_contact_lists_segment_id
ON contact_lists (segment_id)
WHERE segment_id IS NOT NULL;
-- supports: FindBySegmentId, future audit queries
-- NOTE: source_type column already exists (verified: CreateDirectSelectAll sets it to 'contacts').
-- New enum value 'segment' is just a string — no ALTER TYPE needed (column is :string not :enum).
-- Decision 13 (reviewer feedback, 2026-06-30): mirror source_type + segment_id onto the campaign
-- itself so campaign list/detail and the Decision 12 duplicate check don't need to join contact_lists.
-- Migration: database/core/db/migrate/YYYYMMDDHHMMSS_add_source_type_to_message_broadcasts.rb
-- Agent: confirm the actual table/model name for "campaign" before writing this migration —
-- referred to as `message_broadcasts` / `Models::MessageBroadcast` throughout this RFC but not
-- independently re-verified for this specific change.
ALTER TABLE message_broadcasts
ADD COLUMN source_type VARCHAR NULL,
ADD COLUMN segment_id VARCHAR NULL;
CREATE INDEX idx_message_broadcasts_segment_id
ON message_broadcasts (segment_id)
WHERE segment_id IS NOT NULL;
-- supports: Decision 12 duplicate-campaign check (segment_id + message_template_id + status)
-- Idempotency constraint for contact_list_recipients bulk import on Sidekiq retry.
-- Required for activerecord-import's on_duplicate_key_update to resolve conflicts.
-- DEFERRED 2026-07-07 (Open Q #4 reopened) — moved OUT of the Chunk 1 migration.
-- No code references these yet (CreateFromSegmentProcess/Chunk 4 doesn't exist);
-- Postgres's ON CONFLICT (columns) DO UPDATE hard-requires a matching unique index
-- to exist at the time Chunk 4's bulk-import call runs, so ship this migration
-- alongside Chunk 4's implementation instead of speculatively in Chunk 1.
-- Partial indexes keep index size minimal (exclude NULL rows).
CREATE UNIQUE INDEX idx_clr_idempotency_phone
ON contact_list_recipients (contact_list_id, phone_number)
WHERE phone_number IS NOT NULL;
CREATE UNIQUE INDEX idx_clr_idempotency_bsuid
ON contact_list_recipients (contact_list_id, account_uniq_id)
WHERE account_uniq_id IS NOT NULL AND phone_number IS NULL;
-- Only use BSUID as idempotency key when phone absent (phone takes precedence)
-- These two indexes also REPLACE the pre-existing non-unique
-- idx_contact_list_recipients_contact_list_phone_number / _uniq_id indexes
-- (same columns, from 20250505101905_create_contact_list_recipient.rb) —
-- do not stack them alongside the old ones when this migration is written.
-- Decision 15 (user feedback, 2026-07-07) — nullable snapshot marker, source TBD (Open Q #11).
ALTER TABLE contact_lists
ADD COLUMN segment_version VARCHAR NULL;
-- Models::ContactList enum must be updated to add partially_completed:
-- enum progress: { processing: 'processing', success: 'success',
-- failure: 'failure', partially_completed: 'partially_completed' }
-- (string column — no DB migration needed for new enum value)
Cardinality estimate: ~100–1000 segment-based contact lists per month per large org. Negligible growth on the contact_lists table (which already contains thousands of rows).
Example rows:
| id | organization_id | name | source_type | segment_id | progress | version |
|---|---|---|---|---|---|---|
cl_abc | org_xyz | Loyal WA Customers | segment | 683ab... | success | 2 |
cl_def | org_xyz | Q2 Promo Leads | contacts | NULL | success | 2 |
PII classification:
contact_list_recipients.full_name— PII (name)contact_list_recipients.phone_number— PII (phone)contact_extras.extra— may contain PII (email, DOB, etc.) from CDP custom fields
Retention policy: No change from existing policy — contact lists and their recipients are soft-deleted via paranoia on the contact_lists parent. Reviewer feedback (2026-06-30) asked for an explicit retention period given the PII volume this feature introduces (up to 20 000 recipients × PII fields per campaign); indefinite soft-delete was flagged as insufficient. No TTL is defined in Phase 1 — tracked as Open Q #9 (§5), needs a product/infosec decision.
Per-status lifecycle — contact_lists.progress:
| Status value | Visibility | Retention | Restore semantics | Transitions allowed |
|---|---|---|---|---|
processing | shown in recipient list index as "Processing" | until finished_at set | n/a — transient | → success, → failure |
success | shown as "Uploaded" | standard (soft-delete on parent) | allowed | terminal |
failure | shown as "Failed" | standard | n/a | terminal |
Partition / sharding: contact_lists is not partitioned. No change needed.
NoSQL alternative considered: n/a — recipients are small structured rows matching existing contact_list_recipients schema.
Detail 2.4 — APIs
Outbound endpoints (consumers call us)
POST /broadcasts (extended) — Create Broadcast (Segment Audience Path)
| Field | Value |
|---|---|
| Method | POST |
| Path | existing /broadcasts endpoint |
| AuthN/AuthZ | IAG JWT + user must have customers_segment_view permission |
| Idempotency | none (same as existing broadcast creation) |
| Versioning | additive — new optional params; existing callers unaffected |
| Reuse? | extended |
New optional request params added to UserCreateBroadcast contract:
# Added to existing contract params block:
optional(:segment_id).filled(:string) # CDP segment UUID
optional(:segment_name).filled(:string) # CDP segment display name → contact_lists.name
optional(:estimated_recipient_count).filled(:integer, gt?: 0) # from CDP reachability.whatsapp.count
# contact_list_id optionality change when segment_id is present:
rule(:contact_list_id, :segment_id) do
if values[:segment_id].blank? && values[:contact_list_id].blank?
key(:contact_list_id).failure('contact_list_id is missing')
end
end
# When segment_id present: contact_list_id is NOT required (generated by BE).
# Existing callers that provide contact_list_id without segment_id are unaffected.
New validation rules added:
# Rule: when segment_id present, send_at must be >= now + 1 hour
rule(:send_at, :segment_id) do
if values[:segment_id].present? && values[:execute_type] == 'specific'
key.failure('must be at least 1 hour from now for segment campaigns') if values[:send_at].present? && values[:send_at] < Time.now.utc + 1.hour
end
end
# Rule: recurring not allowed with segment
rule(:repeat_period, :segment_id) do
if values[:segment_id].present? && values[:execute_type] == 'campaign_plan'
key.failure('recurring campaigns cannot use segment as audience')
end
end
# Rule (Decision 12, reviewer feedback 2026-06-30): reject duplicate segment+template campaigns
rule(:segment_id, :message_template_id) do
if values[:segment_id].present?
duplicate = Models::MessageBroadcast # agent: confirm actual model/enum names before implementing
.where(organization_id: values[:organization_id], segment_id: values[:segment_id],
message_template_id: values[:message_template_id])
.where.not(status: Models::MessageBroadcast::TERMINAL_STATUSES) # TBD
.exists?
key(:segment_id).failure('a campaign for this segment and template is already in progress') if duplicate
end
end
Pre-contract check (Decision 14, IDOR): before the dry-validation contract even runs its rules above, UserCreateBroadcast must confirm segment_id belongs to organization_id. Exact mechanism depends on what CDP exposes (see Dependency in §1 and Decision 14) — placeholder call shape:
# agent: replace with the confirmed CDP org-scoping mechanism once Decision 14 is resolved
segment = Services::Cdp::SegmentClient.new(segment_id: params[:segment_id], organization_id: params[:organization_id]).fetch_segment_detail
return Failure('Contact list not found') if segment.blank? # do not leak cross-org existence
Example request (segment path):
{
"name": "Q2 Promo — Loyal WA",
"organization_id": "org_xyz",
"message_template_id": "tpl_abc",
"channel_integration_id": "ch_abc",
"user_id": "usr_abc",
"segment_id": "683ab...",
"segment_name": "Loyal WA Customers",
"estimated_recipient_count": 1050,
"execute_type": "specific",
"send_at": "2026-06-19T10:00:00Z",
"parameters": { "body": [] }
}
Response 200 OK (segment path — key fields):
{
"data": {
"id": "broadcast_uuid",
"name": "Q2 Promo — Loyal WA",
"status": "pending",
"send_at": "2026-06-19T10:00:00Z",
"contact_list_id": "cl_abc",
"execute_type": "specific",
"source_type": "segment",
"created_at": "2026-06-18T08:00:00Z"
}
}
Full response shape follows existing POST /broadcasts 200 contract. contact_list_id in the response is the newly created ContactList ID (not FE-provided).
Side effect on campaign creation: UserCreateBroadcast also emits a Datadog metric create_campaign_audience_type with tag type:segment (for Story 15):
Services::Datadog::CaptureCustomMetric
.new(name: :create_campaign_audience_type, tags: ['type:segment'])
.capture
# Emitted after successful broadcast creation, inside result method.
# For existing non-segment path: tag is 'type:recipient_list'.
Error responses:
| Condition | HTTP | Error message |
|---|---|---|
send_at < now + 1h with segment_id | 422 | "must be at least 1 hour from now for segment campaigns" |
| Insufficient balance | 422 | "Your broadcast message requires {total_cost} balance..." |
execute_type=campaign_plan with segment_id | 422 | "recurring campaigns cannot use segment as audience" |
segment_id missing AND contact_list_id missing | 422 | existing "contact_list_id is missing" |
Duplicate non-terminal campaign for same segment_id + message_template_id (Decision 12) | 422 | "a campaign for this segment and template is already in progress" |
segment_id does not belong to caller's organization_id (Decision 14, IDOR) | 404 | "Contact list not found" (same generic message as the recipients endpoint — do not leak cross-org existence) |
GET /contact_lists/:id/recipients (NEW) — List Recipients for Campaign Detail Drawer
| Field | Value |
|---|---|
| Method | GET |
| Path | /contact_lists/:id/recipients |
| AuthN/AuthZ | IAG JWT + organization_id ownership check |
| Idempotency | read-only |
| Versioning | new endpoint |
| Reuse? | new-with-justification — no existing endpoint returns per-row recipients; existing /contact_lists/:id only returns counts |
Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | |
per_page | int | 10 | max 200 |
Response 200 OK:
{
"data": [
{
"full_name": "Andi Santoso",
"phone_number": "+6281234567890",
"account_uniq_id": "bsuid_123"
}
],
"pagination": {
"page": 1,
"per_page": 10,
"total": 1050,
"total_pages": 105
}
}
Recipients are ordered by full_name ASC (PRD Story 12 requirement).
Error responses:
| Condition | HTTP | Message |
|---|---|---|
| contact_list not found or wrong org | 404 | "Contact list not found" |
| Unauthenticated | 401 | existing IAG response |
Inbound webhooks
N/A — no webhooks for this feature.
Detail 2.A — Data Integrity Matrix
| Write path | Transaction scope | Partial failure behavior | Idempotency key + TTL | Consistency model | Duplicate handling | Stale-read |
|---|---|---|---|---|---|---|
CreateFromSegment (ContactList creation) | single DB write + enqueue | If ContactList save fails → return Failure; worker never enqueued | none (new record each time) | strong | n/a — new record | n/a |
UserCreateBroadcast segment path | ContactList creation + Broadcast creation wrapped in ActiveRecord::Base.transaction; worker enqueued AFTER commit (Decision 10) | If Broadcast creation fails → transaction rolls back ContactList too; no orphan. If worker enqueue fails after commit → ContactList stuck in processing (no broadcast references it) — mitigated by Sidekiq in-process reliability. | none | strong (within transaction) | n/a | n/a |
CreateFromSegmentProcess bulk import (per-page) | per-page activerecord-import; page errors caught and skipped (Decision 9) | Per-page error: logged, skipped, continue to next page. 0 contacts total: raise → Sidekiq retry. After 3 retries with 0 contacts: progress='failure'. Partial success: progress='partially_completed'. | (contact_list_id, phone_number) partial unique index + (contact_list_id, account_uniq_id) partial unique index (both added in migration) | eventual | on_duplicate_key_update backed by unique indexes above | n/a |
ContactList.progress update | single UPDATE | If update fails → ES index stale; Sidekiq retry re-processes | n/a | strong | n/a | ES shows stale processing until next index |
Detail 2.B — Concurrency Collision Map
| Resource | Writers | Collision scenario | Resolution | On failure |
|---|---|---|---|---|
contact_lists.progress | CreateFromSegmentProcess worker | Two workers for same contact_list_id (unlikely; one enqueue per ContactList creation) | Only one worker enqueued per ContactList; Sidekiq's unique job plugin could be added as enhancement | Second write wins — acceptable |
contact_list_recipients bulk import | CreateFromSegmentProcess (Sidekiq retry) | Retry re-imports same CDP page → duplicate rows | on_duplicate_key_update on activerecord-import | Idempotent — no error |
Detail 2.C — Async Job / Event Consumer Spec
| Job | Trigger | Input shape | Retry | DLQ | Concurrency | Idempotency key | Per-message timeout | Poison-message handling |
|---|---|---|---|---|---|---|---|---|
CreateRecipientFromSegmentWorker | CreateFromSegment.call → perform_async | JSON: { contact_list_id:, segment_id:, organization_id:, segment_name: } | 3 attempts, Sidekiq default backoff | Sidekiq dead queue | Sidekiq default (no custom concurrency limit) | none (each ContactList = one job) | n/a (per-page CDP call has 30s timeout inside Services::Cdp::SegmentClient) | On exception after 3 retries: job moves to dead queue; contact_list.progress set to failure in rescue block |
Worker implementation sketch (for agent reference — mirrors existing worker pattern):
# app/core/workers/create_recipient_from_segment_worker.rb
# frozen_string_literal: true
class CreateRecipientFromSegmentWorker < AbstractSidekiqWorker
sidekiq_options queue: :create_recipient_from_segment, retry: 3
def perform(args)
params = Hashie::Mash.new(JSON.parse(args))
Repositories::ContactLists::CreateFromSegmentProcess.new(params: params).call
end
end
Process orchestrator sketch (key logic — agent fills in detail from existing CreateDirectSelectAllProcess pattern):
# app/core/domains/repositories/contact_lists/create_from_segment_process.rb
# frozen_string_literal: true
class Repositories::ContactLists::CreateFromSegmentProcess < Repositories::AbstractRepository
MAX_RECIPIENTS = 20_000
PAGE_SIZE = 100
def initialize(params:)
@params = params
end
def call
contact_list = Models::ContactList.find(@params.contact_list_id)
total_created = 0
failed_pages = []
total_pages = nil
page = 1
# STEP 1: Fetch CDP segment property definitions (for header names)
# STEP 2: Paginated loop — per-page error handling (Decision 9)
loop do
begin
result = Services::Cdp::SegmentClient.new(
segment_id: @params.segment_id,
organization_id: @params.organization_id,
page: page,
per_page: PAGE_SIZE
).fetch_customers # returns { customers: [...], total_pages: N }
total_pages = result[:total_pages]
# STEP 3: Build + import recipients
# Use on_duplicate_key_update backed by unique indexes (phone_number + contact_list_id)
# ...import...
total_created += result[:customers].count
rescue => page_error
failed_pages << page
Rails.logger.error(
event: 'segment_page_import_failed',
contact_list_id: @params.contact_list_id,
page: page,
error: page_error.message
)
end
page += 1
break if (total_pages && page > total_pages) || total_created >= MAX_RECIPIENTS
end
# Determine final progress (Decision 9)
if total_created == 0
raise "Zero contacts imported — Sidekiq will retry"
end
new_progress = failed_pages.any? ? 'partially_completed' : 'success'
contact_list.update(
progress: new_progress,
finished_at: Time.now.utc,
error_messages: failed_pages.any? ? { failed_pages: failed_pages } : nil
)
contact_list.__elasticsearch__.index_document refresh: true
Services::Datadog::CaptureCustomMetric
.new(name: :upload_contact_from_segment_status, tags: ["status:#{new_progress}"])
.capture
rescue => e
# Reached when total_created == 0 (raised above) or catastrophic failure
contact_list&.update(progress: 'failure', error_messages: { error: e.message })
contact_list&.__elasticsearch__&.index_document refresh: true
Services::Datadog::CaptureCustomMetric
.new(name: :upload_contact_from_segment_status, tags: ['status:failure'])
.capture
raise # re-raise so Sidekiq retries
end
end
CDP field type mapping (implement in CreateFromSegmentProcess):
CDP type value | ContactListRecipient variable type | Storage format |
|---|---|---|
single_line_text | Text | as-is string |
text_area (CDP's DB name for what the UI labels "Multi-line text"; corrected 2026-06-30 — was mislabeled multi_line_text with a "join array" note, but this is a single-value field, not an array) | Text | as-is single string, with newline/paragraph-break characters replaced by a placeholder before storage (exact placeholder TBD — reviewer suggested x; agent must confirm before implementation, see Open Q #8) |
dropdown | Text | as-is string |
multiple_select | Text | join array values with , |
number — covers CDP's number, currency, and percentage sub-types (clarified 2026-06-30) | Number (stored as string) | value.to_s — CDP stores the raw numeric value only (e.g. 100), never 100% or Rp. 1000; currency/percent symbols are a UI-layer display concern (field_properties), not part of the stored value, so no symbol formatting is applied here |
date | Text (raw timestamp, not normalized to YYYY-MM-DD — corrected 2026-06-30) | as-is CDP timestamp string (e.g. 2023-11-14T07:09:52.074+00:00, ISO 8601 with offset) — CDP always stores the canonical timestamp regardless of the org's UI date-layout preference (DD/MM/YYYY / MM/DD/YYYY / YYYY/MM/DD, which live in CDP's field_properties and are a display-only concern). Hub-core does not reformat; if a template needs a specific display format, that reformatting is FE/template scope. |
url | URL | as-is string — open question: does WhatsApp/Meta auto-linkify a plain URL string in the sent message, or must hub-core wrap it as a markdown-style link? No reviewer answer yet — see Open Q #8. |
file_upload | excluded | skip |
signature | excluded | skip |
gps | excluded | skip |
BSUID fallback (implement in CreateFromSegmentProcess):
phone_number = customer[:phone]&.first # phone is an array; take first element
email = customer[:email]
# Decision 8 (REOPENED 2026-06-30): do NOT trust customer[:id] as account_uniq_id until CDP
# confirms it maps to a hub_core contact. Interim approach — batched lookup per page:
matched_contact = contacts_by_phone_or_email[phone_number] || contacts_by_phone_or_email[email]
account_uniq_id = matched_contact&.account_uniq_id # nil if no internal contact matches
# Eligibility: include if phone OR account_uniq_id present
next if phone_number.blank? && account_uniq_id.blank?
contacts_by_phone_or_emailis built once per page via a single batched query — see Decision 8 consequences (avoid N+1: oneModels::Contactquery per 100-row page, not per customer).
CDP response field mapping (from official CDP API doc GET /api/v1/segments/:id/customers):
| CDP response field | ContactListRecipient field |
|---|---|
name | full_name |
phone[0] | phone_number (first element of array) |
email | used only as a lookup key into Models::Contact (Decision 8) — not stored directly on contact_list_recipients in Phase 1 |
id (customer UUID) | account_uniq_id — confirmed field nameaccount_uniq_id via Models::Contact lookup on phone/email instead |
custom_fields[].key | contact_extras.extra hash key |
custom_fields[].value | contact_extras.extra hash value (after type mapping) |
Decision 7 (resolved): Offset-based pagination confirmed —
page/per_pagemax 100. Cursor-based pagination is NOT used. See §Technical Decisions → Decision 7. Decision 8 (REOPENED 2026-06-30): Theid→account_uniq_idmapping above is no longer trusted as-is. See §Technical Decisions → Decision 8.
Detail 2.D — Responsibility Boundary Matrix
| Step | Owning squad / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 1. User selects segment in campaign form | FE (Chat-2) | User interaction | API call POST /broadcasts with segment_id | FE validation | Story 6 |
| 2. Validate schedule + balance | hub-core (Chat-2) | POST /broadcasts | 422 or continue | return Failure | Stories 9, 11 |
| 3. Create ContactList (progress=processing) | hub-core (Chat-2) | Passed validation | ContactList row + worker enqueued | Failure → no ContactList | Story 10 |
| 4. Fetch CDP segment customers (paginated) | hub-core worker | Worker pickup | contact_list_recipients rows | CDP error → retry 3× → failure | Story 10 |
| 5. Mark ContactList progress=success/failure | hub-core worker | End of loop | ES re-indexed; Datadog metric | rescue block sets failure | Story 10 |
| 6. Execute campaign at scheduled_at | hub-core / wa_cloud | BroadcastSpecificWorker fires | Messages sent via Meta API | checks progress == 'success'; skips if failure | Story 9 |
| 7. Show recipients in Campaign Detail | hub-core API | FE GET request | Paginated recipient list response | 404 if not found | Story 12 |
Detail 2.E — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
ContactList | progress (processing/success/failure) | processing at creation | CreateFromSegmentProcess worker | existing list endpoint + ES | Until worker completes (≤ 1hr budget) |
ContactList | finished_at | null | CreateFromSegmentProcess on completion | existing detail endpoint | — |
ContactList | contacts_count (computed from contact_list_recipients count) | 0 | bulk import | ES index via contacts_count method | Until ES re-index (refresh: true on worker finish) |
3. High-Availability & Security
HA plan: The API pod returns immediately after enqueueing the worker. Campaign execution (BroadcastSpecificWorker) must check contact_list.progress == 'success' before broadcasting — if failure, the campaign is skipped/marked failed. The 1-hour scheduling window gives ample time for the worker to complete even under Sidekiq retry backoff.
If CDP is down for the entire 3-retry window (0 contacts imported), the campaign is blocked (not silently sent with 0 recipients). This is the correct behavior per PRD.
Campaign execution progress check (updated for partially_completed): BroadcastSpecificWorker (wa_cloud app, outside hub-core) must check contact_list.progress IN ('success', 'partially_completed') before broadcasting. If failure, campaign is skipped. This change must be coordinated with the wa_cloud team.
Performance Requirement
- API
POST /broadcastswith segment path: p99 < 500ms (same as existing; no synchronous CDP call added). - Worker completion time: depends on CDP API latency × pages. At 100 customers/page × 200 pages = 200 CDP calls. At 500ms/call p99 ≈ 100s total. Well within 1-hour window.
- Load test plan: simulate 50 concurrent campaign creations with segment audience; assert DB rows created, workers enqueued, no timeouts on API side.
Monitoring & Alerting
- RED metrics:
upload_contact_from_segment_statusDatadog metric withstatus:success/status:failuretags (new — mirrorsupload_contact_from_direct_select_all_contact_statusatcreate_direct_select_all_process.rb:58)- Sidekiq queue depth:
create_recipient_from_segment— alert if > 100 jobs queued
- Trace spans: Sidekiq job start/end; CDP HTTP calls within worker
- Alert threshold:
status:failurerate > 5% over 10-minute window → PagerDuty - SLO: 99% of segment campaigns have their recipient list generated within 30 minutes of creation
- Dashboard: existing Sidekiq dashboard + new Datadog metric panel
Logging
- Structured log fields in worker:
organization_id,contact_list_id,segment_id,page,total_created,elapsed_ms - PII removal:
full_name,phone_numbermust NOT be logged. Log only counts and IDs.
Security Implications
- Threat model: Unauthorized access to another organization's recipient list; IDOR via a hardcoded/guessed
segment_idbelonging to another org (reviewer feedback, 2026-06-30 — see Decision 14); CDP credential leakage; SSRF via segment_id. - AuthN/AuthZ:
organization_idenforced on every ContactList and ContactListRecipient query.segment_idownership is additionally verified againstorganization_idbeforeCreateFromSegmentruns (Decision 14) — this closes the IDOR gap where a caller could pass another org's segment UUID. CDP credentials stored as env vars (not logged). - Input validation:
segment_idis a string — validate UUID format; no URL construction fromsegment_iddirectly (service constructs URL with path template). - Secrets: CDP BasicAuth credentials in environment secrets (not source code). Use
ENV.fetch('CDP_SEGMENT_CLIENT_USERNAME'). - Audit logging:
Models::MessageBroadcastrow recordsuser_id+organization_id+contact_list_idfor all campaign creations. - ISO 27001: CDP customer properties may include PII (name, phone, email, DOB). Stored in
contact_extras.extra(JSONB). Existing encryption viaLOCKBOXshould be evaluated forcontact_extras.extra— see Open Q #5.
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Permitted methods | Tenant scope | Additional constraint | Audit trail |
|---|---|---|---|---|---|
Agent / Supervisor / Admin (Qontak One + CDP module + customers_segment_view) | POST /broadcasts (segment path) | POST | own org only | must have customers_segment_view IAG permission | message_broadcasts row |
| Agent / Supervisor / Admin | GET /contact_lists/:id/recipients | GET | own org only | contact_list must belong to org | n/a |
| System (worker) | CDP S2S | GET | org scoped via organization_id in ContactList | BasicAuth credentials | contact_lists.progress transition |
Detail 3.A — Failure Mode & Retry Catalog
| External call | Timeout | Retries | Circuit breaker | DLQ + retention | Caller behavior on persistent failure |
|---|---|---|---|---|---|
Services::Cdp::SegmentClient (per page) | 30s (Faraday open_timeout: 5, read_timeout: 30) | 3 at HTTP layer (Faraday retry middleware) + 3 Sidekiq job retries | none (Phase 1 — add if CDP proves unstable) | Sidekiq dead queue | contact_list.progress = 'failure'; campaign blocked at execution |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit trail | User-visible? |
|---|---|---|---|---|
| Balance insufficient | UserCreateBroadcast contract | 422; ContactList NOT created | n/a | yes — 422 message |
send_at < now + 1h with segment | UserCreateBroadcast contract rule | 422 | n/a | yes — 422 message |
execute_type=campaign_plan + segment_id | UserCreateBroadcast contract rule | 422 | n/a | yes — 422 message |
| 0 eligible customers from CDP (after all Sidekiq retries) | CreateFromSegmentProcess rescue (all retries exhausted) | progress=failure | contact_lists.progress | indirect (campaign blocked at execution) |
| Some CDP pages fail, some succeed | CreateFromSegmentProcess per-page rescue (Decision 9) | progress=partially_completed; campaign executes with imported contacts | contact_lists.progress + error_messages (failed pages) | indirect (user sees partial count) |
| CDP exhausts retries on ALL pages (0 contacts) | CreateFromSegmentProcess raise → Sidekiq retry 3× | progress=failure after 3 Sidekiq retries | contact_lists.progress | indirect |
Reviewer note (2026-06-30): a true 0-eligible-customers result should be rare in normal operation, since
estimated_recipient_countis already validated at creation time using CDP's own reachability count (Decision 5). This branch is defensive — it covers CDP returning fewer/zero customers than estimated (stale estimate, CDP-side data change) rather than an expected everyday path. Confirmed behavior on 0: markfailureimmediately, same as documented above.
Detail 3.B — Error Response Catalog
| Endpoint | Error code | HTTP status | Message | When it occurs | User-facing? |
|---|---|---|---|---|---|
POST /broadcasts | INVALID_SCHEDULE | 422 | "must be at least 1 hour from now for segment campaigns" | send_at < now + 1hr with segment_id | yes |
POST /broadcasts | INSUFFICIENT_BALANCE | 422 | "Your broadcast message requires {cost} balance..." | balance check fails | yes |
POST /broadcasts | RECURRING_NOT_ALLOWED | 422 | "recurring campaigns cannot use segment as audience" | execute_type=campaign_plan + segment_id | yes |
GET /contact_lists/:id/recipients | NOT_FOUND | 404 | "Contact list not found" | wrong id or org | no (developer-facing) |
Detail 3.C — Compliance & Data Governance
| Field | Classification | Legal basis | Retention | Encryption (rest + transit) | Access audit | Right-to-delete |
|---|---|---|---|---|---|---|
contact_list_recipients.full_name | PII (name) | UU PDP — legitimate interest for campaign sending | Standard retention (deleted with parent ContactList) | transit: HTTPS; rest: see Open Q #5 | organization_id scope | soft-delete on ContactList |
contact_list_recipients.phone_number | PII (phone) | UU PDP — campaign consent | same | transit: HTTPS; rest: see Open Q #5 | organization_id scope | soft-delete on ContactList |
contact_extras.extra | PII (may include email, DOB, etc.) | UU PDP — campaign variables | same | transit: HTTPS; rest: see Open Q #5 | organization_id scope | soft-delete on ContactList |
4. Backwards Compatibility and Rollout Plan
Compatibility
POST /broadcastsendpoint: additive — new optional paramssegment_idandestimated_recipient_count. Existing callers who omit these params are entirely unaffected (theif segment_id.present?branch is only taken whensegment_idis provided).- Existing
contact_listsdata:segment_idcolumn added as nullable — no existing rows are affected. source_typecolumn: existing value'contacts'unchanged;'segment'is a new string value.
Rollout Strategy
- Feature flag:
send_campaign_with_segment(new — register viaServices::Preference.new.add(...)at deploy time; default OFF) - Gate in
UserCreateBroadcast:if params[:segment_id].present? && !Services::Preference.new.enabled?(:send_campaign_with_segment, organization_id: params[:organization_id]) → Failure('Feature not available') - Migration sequence:
- Deploy migration (
add segment_id to contact_lists) → add nullable column, no downtime. - Deploy hub-core code with flag OFF.
- Enable flag for internal orgs → smoke test.
- Enable flag for 5% of Qontak One orgs with CDP module → monitor.
- Enable flag 100%.
- Deploy migration (
- Rollout stages:
| Stage | Audience | Go/no-go evidence |
|---|---|---|
| Internal | Mekari internal orgs | upload_contact_from_segment_status:success > 0; no error spike |
| 5% | Qontak One + CDP module orgs | failure rate < 2%; p99 worker completion < 30min |
| 100% | All Qontak One + CDP module orgs | failure rate < 2% sustained 24h |
- Rollback trigger:
status:failurerate > 10% or p99 worker time > 45 minutes. - Rollback mechanism: Toggle flag OFF. Existing campaigns in-flight that used segment audience: their ContactList may be in
processingorfailure— campaign execution will check progress and skip if notsuccess. No data corruption. - PIC: Hilmi Dama (lead), Burhanudin Hakim (backup).
Detail 4.A — Configuration Contract
| Env var / config / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|
send_campaign_with_segment (Flipper flag) | boolean | false | yes | Chat-2 at deploy | no |
CDP_SEGMENT_CLIENT_BASE_URL | string | https://contact-service.qontak.net | yes | Platform/Infra | no |
CDP_SEGMENT_CLIENT_USERNAME | string | — | yes | Platform/Infra (Vault) | yes |
CDP_SEGMENT_CLIENT_PASSWORD | string | — | yes | Platform/Infra (Vault) | yes |
CDP_SEGMENT_CLIENT_TIMEOUT_SECONDS | integer | 30 | no | Chat-2 | no |
Detail 4.B — Test Plan
| Layer | Command (source) | What it must prove |
|---|---|---|
| Unit | bundle exec rspec app/core/domains/repositories/contact_lists/create_from_segment_spec.rb (source: AGENTS.md Safe commands) | CreateFromSegment creates ContactList with correct fields + enqueues worker |
| Unit | bundle exec rspec app/core/domains/repositories/contact_lists/create_from_segment_process_spec.rb | CreateFromSegmentProcess fetches CDP pages, maps properties, creates recipients, marks progress |
| Unit | bundle exec rspec app/core/workers/create_recipient_from_segment_worker_spec.rb | Worker delegates to process repo; Sidekiq retry semantics |
| Integration | bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb | Existing spec still passes (no regression); segment path happy + failure cases added, including: send_now bypasses the 1-hour rule (Decision 11), duplicate segment+template rejected (Decision 12), wrong-org segment_id rejected (Decision 14) |
| Contract | bundle exec rspec app/apps/broadcast_service/ | No regression in broadcast service |
| Full suite | bundle exec rspec (source: AGENTS.md) | Full suite green |
| Lint | bundle exec rubocop --no-color (source: AGENTS.md) | 0 offenses |
| Security | bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q (source: AGENTS.md) | No new HIGH findings |
Detail 4.C — Agent Execution Plan
| Order | Chunk | Files to modify/create | Commands to run | Acceptance criteria |
|---|---|---|---|---|
| 1 | DB migration: add segment_id + segment_version to contact_lists (Decision 15 — QC-23030) | create database/core/db/migrate/YYYYMMDDHHMMSS_add_segment_id_to_contact_lists.rb | bundle exec rake db:migrate | contact_lists has segment_id nullable string + partial index and nullable segment_version string; bundle exec rake db:rollback works. Idempotency indexes deferred to Chunk 4 (see row 4) — not part of this migration. message_broadcasts.source_type/segment_id (Decision 13) tracked under a separate ticket, also not part of this migration. |
| 2 | CDP service client | create app/apps/broadcast_service/services/cdp/segment_client.rb | bundle exec rubocop app/apps/broadcast_service/services/cdp/ | Service class responds to fetch_customers; Faraday timeouts configured; BasicAuth from env |
| 3 | CreateFromSegment entry point repository | create app/core/domains/repositories/contact_lists/create_from_segment.rb + spec | bundle exec rspec app/core/domains/repositories/contact_lists/create_from_segment_spec.rb | Happy path: ContactList created with source_type='segment', segment_id, version='2', progress='processing'; worker enqueued; spec covers wrong organization_id |
| 4 | DB migration: idempotency indexes on contact_list_recipients (deferred from Chunk 1, 2026-07-07 — Open Q #4 reopened) + CreateRecipientFromSegmentWorker + CreateFromSegmentProcess — blocked on Open Q #7 for the identity-resolution logic | create migration adding idx_clr_idempotency_phone/idx_clr_idempotency_bsuid (replacing the old non-unique idx_contact_list_recipients_contact_list_phone_number/_uniq_id indexes — see §2.3 DDL); app/core/workers/create_recipient_from_segment_worker.rb, app/core/domains/repositories/contact_lists/create_from_segment_process.rb + specs | bundle exec rake db:migrate; bundle exec rspec app/core/workers/create_recipient_from_segment_worker_spec.rb app/core/domains/repositories/contact_lists/create_from_segment_process_spec.rb | Both partial unique indexes exist; bulk import uses on_duplicate_key_update with explicit conflict_target: referencing them (required — Postgres rejects ON CONFLICT on columns without a backing unique constraint); happy path: recipients created, progress='success', ES indexed, Datadog metric emitted; cap at 20 000; CDP 0-customer case → progress='failure'; CDP timeout → raises (Sidekiq retry); account_uniq_id resolution per Decision 8 (batched Models::Contact lookup on phone/email, not a direct CDP id copy) — spec covers "CDP customer has no matching internal contact" |
| 5 | Extend UserCreateBroadcast for segment path | modify app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb | bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb | Segment path: ContactList + Broadcast creation wrapped in ActiveRecord::Base.transaction with skip_enqueue: true on CreateFromSegment (Decision 10); worker enqueued after transaction commits; validates 1-hour window only for execute_type=specific, send_now bypasses it (Decision 11); rejects duplicate non-terminal segment+template campaigns (Decision 12); sets message_broadcasts.source_type/segment_id (Decision 13); validates segment_id ownership against organization_id once Open Q #10 resolved (Decision 14 — placeholder call shape until then); validates balance with estimated count; blocks recurring; Datadog metric create_campaign_audience_type emitted with type:segment or type:recipient_list tag; existing recipient-list path unaffected |
| 6 | GET /contact_lists/:id/recipients repository + interactor (hub-core) | create app/core/domains/repositories/contact_list_recipients/list.rb + spec; create interactor in hub-core; route registration is in hub-service (separate ticket) | bundle exec rspec app/core/domains/repositories/contact_list_recipients/list_spec.rb | hub-core: repository returns recipients sorted by full_name ASC; pagination correct; wrong org → 404. hub-service route registration is a separate change outside hub-core scope. |
| 7 | Feature flag registration | register send_campaign_with_segment flag via Services::Preference.new.add(...) in a rake task or migration comment | bundle exec rubocop --no-color | Flag exists in DB/Flipper after task runs |
| 8 | Full suite + lint + security scan | no new files | bundle exec rubocop --no-color && bundle exec rspec && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q | All green, 0 Rubocop offenses, no new Brakeman HIGH findings |
Detail 4.D — Verification & Rollback Recipe
Pre-merge verification commands (in order):
bundle exec rubocop --no-colorbundle exec rspec spec/core/domains/repositories/contact_lists/create_from_segment_spec.rb spec/core/domains/repositories/contact_lists/create_from_segment_process_spec.rb spec/core/workers/create_recipient_from_segment_worker_spec.rbbundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rbbundle exec rspec(full suite)bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q
Post-deploy verification signals:
- Datadog metric
upload_contact_from_segment_statuswithstatus:successappearing > 0 after first test campaign creation. - Sidekiq queue
create_recipient_from_segmentdepth returns to 0 after test campaigns complete. contact_listsrows withsource_type='segment'haveprogress='success'and non-nullfinished_at.
Rollback recipe (in order):
- Toggle
send_campaign_with_segmentflag OFF viaServices::Preference.new.disable(:send_campaign_with_segment). - Confirm Sidekiq queue
create_recipient_from_segmentdrains or jobs fail gracefully (no new jobs enqueued after flag OFF). - If migration must be rolled back:
bundle exec rake db:rollback—segment_idcolumn drops; no existing data affected (column was nullable). - Confirm Datadog metric
upload_contact_from_segment_statusstops receiving new data points.
Detail 4.E — Resource & Cost Notes
- Compute: Worker jobs run on existing Sidekiq infrastructure. Max ~200 CDP HTTP calls per segment campaign = low CPU. No new pods needed for Phase 1.
- DB load delta: Up to 20 000 new
contact_list_recipientsrows +contact_extrasrows per segment campaign. At 100 campaigns/day = 2M rows/day peak — evaluate against current write headroom. Index oncontact_list_idalready exists via existing pattern. - Network egress: CDP calls are internal S2S on
contact-service.qontak.net— intra-Mekari. Not external internet egress. - Storage growth: ~2KB per recipient (row + contact_extras) × 20 000 = ~40MB per campaign. Consistent with existing Direct Select All loads.
5. Concern, Questions, or Known Limitations
-
Open Q #1 — CDP pagination protocolCLOSED (Decision 7): Offset-based pagination confirmed —page/per_pagemax 100. Cursor-based pagination is NOT used. 20K cap makes drift negligible. See §Technical Decisions → Decision 7. -
Open Q #2 — CDP API p99 latency (still open): The official CDP API doc does not specify performance targets. The worker completion SLO (< 30min) depends on CDP response time. Action required: measure CDP
GET /api/v1/segments/:id/customerslatency in staging before rollout. -
Open Q #3 — Orphan ContactList on Broadcast creation failureCLOSED (Decision 10): ContactList + Broadcast creation are now wrapped inActiveRecord::Base.transaction. Worker is enqueued only after the transaction commits. No orphan ContactList is possible if Broadcast creation fails. See §Technical Decisions → Decision 10. -
Open Q #4 — Idempotency key on
contact_list_recipients(REOPENED 2026-07-07, non-blocking for Chunk 1): Design confirmed — two partial unique indexes,idx_clr_idempotency_phoneon(contact_list_id, phone_number) WHERE phone_number IS NOT NULLandidx_clr_idempotency_bsuidon(contact_list_id, account_uniq_id) WHERE account_uniq_id IS NOT NULL AND phone_number IS NULL, replacing the pre-existing non-unique indexes on the same columns. Deferred out of the Chunk 1 (QC-23030) migration — no code references them untilCreateFromSegmentProcess(Chunk 4) is implemented, and Chunk 4's bulk import must pass an explicitconflict_target:referencing them foron_duplicate_key_updateto work (Postgres requires a matching unique constraint forON CONFLICT (columns)to be valid SQL at all). Ship this migration alongside Chunk 4 instead. See §2.3 DDL. -
Open Q #5 — PII encryption for
contact_extras.extra(still open): CDP custom fields stored incontact_extras.extra(JSONB) may include email, DOB, etc. Verify if LOCKBOX encryption applies tocontact_extras.extrafor existing flows. If not, this is a compliance gap per UU PDP. Infosec approver sign-off required before 100% rollout. -
Open Q #6 — CDP API field name discrepancyREOPENED 2026-06-30 (was CLOSED 2026-06-22): Theid(UUID) →account_uniq_idmapping is no longer trusted — reviewer feedback states CDP segmentation only carriesphone/emailper customer. Superseded by Decision 8 (REOPENED) and tracked going forward as Open Q #7 below. Do not treat this as resolved. -
Open Q #7 — CDP identity field / contact resolution (NEW, blocking Chunk 4): Does the CDP segment-members response provide any field usable as a hub_core contact identifier, or only
phone/email? Interim design (Decision 8) resolves identity via a batchedModels::Contactlookup onphone/emailper page. Action required: confirm with CDP team (Jovi Renaldo / Ghozi Humama) before implementing Chunk 4 (CreateFromSegmentProcess) — a wrong assumption here silently produces recipient rows with no working BSUID. -
Open Q #8 — CDP field formatting confirmations (non-blocking): (a) exact placeholder to substitute for newline/paragraph breaks when storing
text_area(multi-line text) values — reviewer suggestedxbut this was not a final confirmation; (b) whether aurl-type field auto-linkifies in the sent WhatsApp message (Meta/WhatsApp rendering) or whether hub-core must format it as a link itself. Neither blocks Chunk 4 structurally, but both affect what gets stored incontact_extras.extraand should be confirmed before Chunk 4 ships to avoid a follow-up data migration. -
Open Q #9 — Retention policy for segment-derived PII (non-blocking for dev, blocking for 100% rollout sign-off): Reviewer feedback (2026-06-30) asked for an explicit retention/TTL policy for
contact_list_recipients/contact_extrasrows generated from segments, given the PII volume (up to 20 000 rows × PII fields per campaign). Current Phase 1 design has no TTL beyond the existing indefinite soft-delete. Needs a product/infosec decision — track alongside Open Q #5 (PII encryption) as an Infosec sign-off item. -
Open Q #10 — Segment ownership validation mechanism (blocking Decision 14 implementation, non-blocking for other chunks): Decision 14 requires verifying
segment_idbelongs to the caller'sorganization_idbefore creating a ContactList, but it's not yet confirmed whether CDP's API supports an org-scoped lookup hub-core can call, or whether hub-core must rely entirely on CDP enforcing tenant scoping server-side whenorganization_idis passed on the request. Action required: confirm with CDP team before Chunk 5 (UserCreateBroadcastextension) is finalized. -
Open Q #11 — CDP segment version/definition marker (NEW, non-blocking for Chunk 1): Does CDP expose any field (segment-detail endpoint or elsewhere) reflecting the segment's definition version or last-modified state? Needed to populate
contact_lists.segment_version(Decision 15) with real data. Non-blocking for the Chunk 1 migration (column is nullable/additive) but blocksCreateFromSegmentProcess(Chunk 4) from actually writing a meaningful value — until resolved, the column staysNULL. -
Known limitation — 20 000 cap: Phase 1 hard caps at 20 000 recipients alphabetically. Users are informed via FE warning (PRD Story 8.2). Phase 2 (batch splitting) is deferred.
-
Known limitation — Segment membership is a snapshot: Customers who join or leave the segment after campaign creation are not reflected. This is an explicit product decision.
-
Known limitation —
partially_completedcampaign execution:BroadcastSpecificWorker(wa_cloud app, outside hub-core) currently checkscontact_list.progress == 'success'before broadcasting. This check must be updated toprogress IN ('success', 'partially_completed')for campaigns with partially imported recipients to execute. This change must be coordinated with the wa_cloud team and is a prerequisite for Decision 9 to take effect end-to-end.
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-06-18 | Hilmi Dama | Initial RFC draft. Open Questions #1–6 need resolution before chunk 2 (CDP client) can start. |
| 2026-06-22 | Hilmi Dama | Resolved Open Questions #1, #3, #4, #6. Decision 7: offset pagination confirmed (page/per_page max 100). Decision 9: per-page error handling with partially_completed progress status — partial import proceeds, campaign executes. Decision 10: transaction wrapping for ContactList + Broadcast creation; worker enqueued after commit. Decision (Q#4): two partial unique indexes added to migration for idempotency. Decision (Q#6): CDP field id = BSUID, phone = array (take first). Route for GET /contact_lists/:id/recipients confirmed in hub-service; hub-core implements repository + interactor only. Open Questions #2 (p99 latency) and #5 (PII encryption) remain open — non-blocking for Phase 1 development. RFC updated to status: AGREED pending reviewer sign-off. |
| 2026-07-01 | Hilmi Dama (incorporating Confluence review comments from Isna Rahmatul Khoir, Jovi Renaldo, Ghozi Humama, Evelin Suwantio — 2026-06-30) | Reopened Open Q #6 as Open Q #7 (blocking Chunk 4): CDP segmentation may only carry phone/email, not a hub_core-usable identifier — Decision 8 reopened; interim design resolves account_uniq_id via a batched Models::Contact lookup instead of copying CDP id directly. Added Decision 11 (Send Now sending option, bypasses the 1-hour window which only ever applied to execute_type=specific). Added Decision 12 (reject duplicate campaigns on same segment_id + message_template_id). Added Decision 13 (message_broadcasts.source_type + segment_id columns, mirroring contact_lists). Added Decision 14 (IDOR mitigation — verify segment_id ownership against organization_id before ContactList creation; tracked as Open Q #10). Corrected the CDP type-mapping table: multi_line_text renamed to text_area with corrected storage note (was incorrectly documented as array-join); clarified number covers currency/percentage sub-types as raw values only; clarified date stores the raw CDP ISO-8601 timestamp, not a normalized YYYY-MM-DD. Added Open Q #8 (text_area placeholder + URL auto-linking) and Open Q #9 (explicit retention/TTL for segment-derived PII, non-blocking for dev but needed before 100% rollout sign-off). Added an explicit async recipient-generation UX contract (§2.2.A) describing redirect-before-completion behavior and failure surfacing. RFC status held at RFC (not AGREED) pending resolution of Open Q #7 and #10, both blocking for their respective chunks — see updated §7 gates. |
| 2026-07-07 | Hilmi Dama (implementing Chunk 1 — DB migration for QC-23030) | While implementing the Chunk 1 migration: (1) Added Decision 15 / Open Q #11: new nullable contact_lists.segment_version column to record which CDP segment definition state a recipient snapshot was generated against. No CDP field for this is confirmed yet — column is additive/nullable so Chunk 1 isn't blocked; population logic deferred to Chunk 4 pending CDP team confirmation. (2) Reopened Open Q #4 (idempotency indexes): confirmed the design (idx_clr_idempotency_phone/idx_clr_idempotency_bsuid, replacing the pre-existing non-unique idx_contact_list_recipients_contact_list_phone_number/_uniq_id indexes on the same columns — those old names aren't referenced elsewhere, safe to drop when this ships) but moved the indexes out of Chunk 1 entirely — no code consumes them until CreateFromSegmentProcess (Chunk 4) exists, so they now ship as part of Chunk 4's own migration instead of speculatively upfront. QC-23030's final scope is narrowed to contact_lists.segment_id + contact_lists.segment_version only — the Jira ticket's description/AC (which also lists the idempotency indexes) should be updated to match, or the indexes tracked as a new ticket alongside Chunk 4. message_broadcasts.source_type/segment_id (Decision 13) remains out of scope for QC-23030 under a separate ticket, as previously noted. |
7. Ready for agent execution
Partially — reviewer feedback on 2026-06-30 reopened one closed decision and added two new blocking gates. Chunks 1, 2, 3, 6, 7 can proceed now; Chunk 4 is blocked on Open Q #7, and the ownership-check portion of Chunk 5 is blocked on Open Q #10.
Gates checked
- Decision 7 (was Open Q #1): Offset-based pagination confirmed.
Services::Cdp::SegmentClientusespage/per_page(max 100). No cursor logic needed. - Decision 9 (was Open Q #3): Per-page error handling with
partially_completedstatus. Partial import proceeds; campaign executes onsuccess OR partially_completed. Zero-contact case raises for Sidekiq retry. - Decision 10 (was Open Q #3 orphan risk):
ActiveRecord::Base.transactionwraps ContactList + Broadcast creation.CreateFromSegmentacceptsskip_enqueue: true. Worker enqueued after commit. - Idempotency indexes (Open Q #4, REOPENED 2026-07-07): Design confirmed (
idx_clr_idempotency_phone,idx_clr_idempotency_bsuid) but deferred out of the Chunk 1 migration — no consumer exists until Chunk 4 (CreateFromSegmentProcess) is implemented. Ship as part of Chunk 4's own migration instead. Non-blocking for Chunk 1. - Decision 8 (was Open Q #6, REOPENED 2026-06-30): CDP field mapping is NOT confirmed —
id→account_uniq_idis retracted pending CDP team confirmation of the segment-members response shape. Blocks Chunk 4. See Open Q #7. - Route ownership:
GET /contact_lists/:id/recipientsroute lives in hub-service. hub-core implements repository + interactor only (Chunk 6 scope updated). - Decision 11 (Send Now): No new mechanism needed beyond scoping the existing 1-hour rule to
execute_type=specific— already the case in the Decision 4 contract rule. Dispatch timing is gated by the existingcontact_list.progresscheck (Decision 9). - Decision 12 (duplicate-campaign check): Contract rule designed in §2.4; needs
Models::MessageBroadcast's actual status enum confirmed during Chunk 5 implementation (not a separate blocking gate — normal implementation detail). - Decision 13 (
message_broadcasts.source_type/segment_id): Additive migration; folded into Chunk 1. - Decision 14 (IDOR — segment ownership check): Mechanism not confirmed — depends on whether CDP supports org-scoped segment lookup. Blocks the ownership-check portion of Chunk 5. See Open Q #10.
- Decision 15 (
segment_version, NEW 2026-07-07): CDP data source not confirmed. Non-blocking for Chunk 1 (column added nullable/additive); blocks Chunk 4 from populating it with a meaningful value. See Open Q #11.
Remaining non-blocking items (do not block execution)
- Open Q #2 (CDP p99 latency): measure in staging before rollout go/no-go. Non-blocking for implementation.
- Open Q #5 (PII encryption for
contact_extras.extra): Infosec review required before 100% rollout. Non-blocking for development. - Open Q #8 (text_area placeholder + URL auto-linking): confirm before Chunk 4 ships to avoid a follow-up data migration; does not block starting Chunk 4's non-CDP-identity parts.
- Open Q #9 (retention/TTL for segment-derived PII): needed before 100% rollout sign-off, not before development.
- Open Q #11 (
segment_versiondata source): confirm with CDP team before Chunk 4 populates the column; the column itself ships nullable in Chunk 1 regardless. - CDP credentials: must be provisioned in staging before Chunk 2 can be end-to-end tested. Non-blocking for Chunks 1, 3.
- wa_cloud
BroadcastSpecificWorker: must be updated to checkprogress IN ('success', 'partially_completed')— coordinate with wa_cloud team. Non-blocking for hub-core work.
Blocking items — must resolve before the affected chunk starts
- Open Q #7 (Decision 8 reopened): confirm with CDP team whether the segment-members response provides a usable hub_core identifier, or only
phone/email. Blocks Chunk 4 (CreateFromSegmentProcess) — implementing against the wrong assumption again would silently produce unusable BSUID values. - Open Q #10 (Decision 14): confirm with CDP team whether an org-scoped segment lookup exists. Blocks the IDOR-check portion of Chunk 5 — the rest of Chunk 5 (schedule rule, balance validation, duplicate check,
source_type) can proceed in parallel using the placeholder call shape in §2.4, with the IDOR check wired in once confirmed.
Recommended execution order
1 → 3 → 2 → [Open Q #7] → 4 → [Open Q #10] → 5 → 6 → 7 → 8
- DB migration (Chunk 1) — no dependencies; now also adds
message_broadcasts.source_type/segment_id(Decision 13) - Feature flag registration (Chunk 7) — can run any time after Chunk 1
- CDP service client (Chunk 2) — requires CDP credentials in staging
- Resolve Open Q #7 with the CDP team — blocking, do this before Chunk 4 implementation, not during it
CreateFromSegment+ worker + process (Chunks 3–4) — Chunk 3 can start immediately; Chunk 4's identity-resolution logic requires Open Q #7 resolved- Resolve Open Q #10 with the CDP team — blocking, do this before finalizing the IDOR check in Chunk 5
UserCreateBroadcastextension (Chunk 5) — requires Chunks 3–4 for the segment path; requires Open Q #10 for the IDOR check specifically; schedule/duplicate/balance/source_type sub-parts can proceed without itGET /contact_lists/:id/recipientsrepository + interactor (Chunk 6) — independent- wa_cloud
BroadcastSpecificWorkerupdate (out-of-scope for hub-core — coordinate separately) - Full suite + lint + security scan (Chunk 8)
Optional: hand off to
rfc-reviewerfor a second-pass score to confirm the updated RFC reachesSHIPverdict.