Skip to main content

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 /expand block 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

FieldValueNotes
StatusRFCIDEA / RFC / ABANDON / AGREED
OwnerRevenue (Chat-2)
Author(s)Hilmi Dama
ReviewersBurhanudin Hakim, Fachriza Ramanda
Approver(s)Tech Lead Revenue (Chat-2), Infosec Approver (TBD)
Submitted Date2026-06-18
Last Updated2026-07-01
Target Release2026-Q2
Related DocumentsPRD · Async 1-pager · CDP API Design
DiscussionTBD

Type: backend
Sub-type: new-feature

Sections at a Glance

  1. Overview (PRD-to-Schema Derivation, story map, decisions index)
  2. Technical Design (Infrastructure Topology → ADR Technical Decisions → Repo Reading Guide → Architecture → Sequence Diagrams → DDL → APIs → Async Spec)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (Agent Execution Plan + Verification Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. 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

  1. A campaign created with a segment audience has its ContactListRecipient rows fully populated before scheduled_at (1-hour buffer).
  2. Campaigns with insufficient balance are rejected before any ContactList record is created.
  3. Worker retries on CDP API failures; contact_list.progress transitions to failure after all retries exhausted, and campaign execution is blocked.
  4. rspec suite green on all new specs (no mocks except at CDP HTTP boundary).
  5. Datadog metric upload_contact_from_segment_status emitted with status:success or status:failure tag 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.

Assumptions

  1. CDP team ships GET /api/v1/segments/:segment_id/customers (S2S BasicAuth) before hub-core integration begins. This is a hard dependency.
  2. source_type column already exists on contact_lists (verified: CreateDirectSelectAll sets source_type: 'contacts' at app/core/domains/repositories/contact_lists/create_direct_select_all.rb:19).
  3. ContactListRecipient (version 2) is the canonical recipient row — decoupling_recipient_list flag is always enabled for segment-based lists.
  4. CDP segment membership is a snapshot taken at recipient creation time — segment changes between submit and send do not affect the generated list.
  5. estimated_recipient_count (the reachability.whatsapp.count from 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.
  6. CDP API pagination: offset-based (page / per_page max 100), confirmed. The 20,000-recipient Phase 1 cap makes offset-drift risk negligible. Cursor-based pagination is NOT used.
  7. CDP customer id (UUID) can be used directly as account_uniq_id (BSUID)retracted 2026-06-30. Reviewer feedback (Jovi Renaldo, Isna Rahmatul Khoir) states CDP segmentation only carries phone and email per 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

DependencyOwnerStatus
GET /api/v1/segments/:segment_id/customers (CDP S2S)CDP teamNeeded — confirm readiness
CDP BasicAuth credentials in hub environment secretsInfra/PlatformNeeded
Feature flag send_campaign_with_segmentChat-2New — register at deploy
Existing decoupling_recipient_list feature flagPlatformAssumed always ON for segment lists
Confirmation of CDP customer identity field shape (id/phone/email only?)CDP teamNeeded — blocking Chunk 4, see Decision 8
CDP org-scoping on segment detail lookup (for IDOR check on segment_id)CDP teamNeeded — see Decision 14

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

PRD-described entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event)Enforced whereSource
Segment is selected as audience sourcecontact_lists.source_type = 'segment'Campaign detail response includes audience_typeRepositories::ContactLists::CreateFromSegmentPRD §Phase 1
CDP segment ID linked to contact listcontact_lists.segment_id :string (new)contact_lists.segment_id readable by campaign queriesmigration + Models::ContactListAsync 1-pager §Data Model
Recipient list name = segment namecontact_lists.name = segment_namecontact_lists.name in recipient list APICreateFromSegmentPRD Story 10, scenario 10.7
Async recipient generation from CDPcontact_list_recipients rows via CreateRecipientFromSegmentWorkerprogress field on ContactListWorker + CreateFromSegmentProcessPRD Story 10
Max 20 000 recipients (Phase 1 cap)contact_list_recipients capped at 20 000 rowscontacts_count on ContactListCreateFromSegmentProcess hard capPRD Story 10.2
WA-eligible customers only (phone OR BSUID)contact_list_recipients.phone_number OR account_uniq_id populatedfilter applied at CDP API callCDP API channel=whatsapp filterPRD Story 10.1
Up to 150 customer properties as recipient variablescontact_extras.extra hash (key = property name, value = mapped value)contact_variables on ContactList (ES)CreateFromSegmentProcess type mappingPRD Story 10.3
CDP property type mapping to recipient variable typesstored as Text/Number/Date/URL strings in contact_extras.extrarecipient list variables in campaign APICreateFromSegmentProcess mapping logicPRD Story 10.5
Campaign must be scheduled ≥ 1 hour in advance when segment audiencemessage_broadcasts.send_at >= Time.now + 1.hour422 response if violatedUserCreateBroadcast contract rulePRD Story 11
Balance validation with 10 % buffertransient check against billingFailure response before ContactList creationUserCreateBroadcastValidateBroadcastQuotaPRD Story 9.2
recurring sending option disabled for segment audiencemessage_broadcasts.execute_type must not be campaign_plan422 if execute_type=campaign_plan + segment_id presentContract rule in UserCreateBroadcastPRD Story 9.3
Customer list viewable in Campaign Detailcontact_list_recipients rows sorted by full_name ASCGET /contact_lists/:id/recipientsRepositories::ContactListRecipients::List (new)PRD Story 12
Source field shown as "Segment" in Recipient List indexcontact_lists.source_type = 'segment' already indexed in ESexisting list endpoint filters/displays source_typeES mapping already has source_typePRD Story 13
BSUID used when phone number unavailablecontact_list_recipients.account_uniq_idincluded in recipient rowCreateFromSegmentProcess mappingPRD Story 14
Datadog metric for audience sourceno persistenceupload_contact_from_segment_status Datadog metricCreateFromSegmentProcessPRD 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_listsmigration + Models::MessageBroadcastreviewer feedback, 2026-06-30
Prevent duplicate campaign for the same segmentuniqueness check across message_broadcasts.segment_id + message_template_id + status422 on duplicate create attemptUserCreateBroadcast contract rulereviewer feedback, 2026-06-30
Segment ownership must be verified (IDOR)n/a — enforced at request time, not persisted403/404 if segment_id does not belong to caller's organization_idUserCreateBroadcast + Repositories::ContactListRecipients::Listreviewer feedback, 2026-06-30
Track which CDP segment definition state a recipient snapshot was generated againstcontact_lists.segment_version :string (new, nullable)n/a — internal/debug field, not yet surfaced on any API responseCreateFromSegmentProcess (population pending Open Q #11)user feedback, 2026-07-07

Detail 1.A — PRD Traceability Matrix

Forward (PRD → RFC):

PRD requirementService / endpoint / jobRFC section
Create campaign with segment audienceUserCreateBroadcast (extended)§2 Technical Decisions, §2.4 APIs
Async generate recipients from segmentCreateRecipientFromSegmentWorker + CreateFromSegmentProcess§2.C Async Spec
1-hour schedule windowUserCreateBroadcast contract rule§2.4 APIs
Balance validation 10 % bufferValidateBroadcastQuota (existing, extended)§2.4 APIs
CDP customer properties → recipient variablesCreateFromSegmentProcess type mapping§2.C, §2.3 DDL
Campaign detail — customer list from segmentGET /contact_lists/:id/recipients§2.4 APIs
BSUID fallbackCreateFromSegmentProcess mapping§2.C
Datadog trackingCaptureCustomMetric in worker§2.C

Reverse (RFC → PRD):

New endpoint / table / servicePRD need it serves
contact_lists.segment_id columnAsync worker needs to call CDP for the right segment
Repositories::ContactLists::CreateFromSegmentEntry point mirrors CreateDirectSelectAll for segment source
Services::Cdp::SegmentClientHTTP wrapper isolates CDP API from business logic
GET /contact_lists/:id/recipientsCampaign detail drawer (Story 12)
message_broadcasts.source_type columnReviewer 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 surfaceConsumerRequired readsRequired writesStatus surface
Campaign Create Form (Qontak One)FE webGET /iag/v1/segments (CDP direct)POST /broadcasts (extended)contact_list.progress polling
Campaign Detail drawer — customer listFE webGET /contact_lists/:id/recipients (new)n/a — fully covered by async writecontact_list.progress
Recipient Lists indexFE webexisting list endpoint + source_type filtern/acontact_list.progress

Role Coverage

PRD roleAuthorization mechanismEndpoints permittedCross-tenant?Audit trail
Agent / Supervisor / Admin (Qontak One)IAG JWT + customers_segment_view permissionPOST /broadcasts (extended), GET /contact_lists/:id/recipientsno — organization_id scopedModels::MessageBroadcast row creation
System (worker)internal / no auth boundaryWorker job processes asynchronouslynoContactList.progress transitions

PRD Section Coverage

PRD sectionTitleRFC section
TL;DR / Problem StatementSegment as campaign audience§1 Overview
Phase 1 feature descriptionSend Campaign with Segment up to 20K§1 Overview, §2
Story 1–8FE UX updatesn/a — covered in FE RFC
Story 9Create Campaign with Segment as Audience§2.4, §2.C, §4.C chunk 5
Story 10Automatically Generate Recipients§2.C, §2.3 DDL
Story 11Minimum Scheduling Window§2.4, Decision 4
Story 12Campaign Detail — Customer List§2.4 APIs
Story 13Recipient Lists index — source fieldn/a — source_type already indexed in ES; no new BE needed
Story 14BSUID fallback§2.C
Story 15Measure Campaign Usage§2.C (Datadog)
Phase 2Batching for >20Kn/a — deferred
CDP Field MappingProperty type mapping§2.C
Role AccessRBAC for segment view§3 Security
Package AvailabilityQontak One with CDP module§3 Feature Flag
Release DependenciesCDP team readiness§5 Open Questions

Detail 1.B — Key Decisions Summary

#DecisionChosen option§2 block
1Storage: how to link segment to contact listAdd segment_id column to contact_listsDecision 1
2Sync vs async for recipient generationAsync (Sidekiq) — mirrors CreateDirectSelectAllDecision 2
3CDP integration methodDirect HTTP (new Services::Cdp::SegmentClient)Decision 3
4Schedule minimum for segment campaigns1-hour window enforced in UserCreateBroadcast contractDecision 4
5Balance validation with segment audienceUse FE-provided estimated count; validate before ContactList creationDecision 5
6Reuse vs new for create entry pointNew Repositories::ContactLists::CreateFromSegment (clone pattern from CreateDirectSelectAll)Decision 6
7Pagination for CDP S2S endpointOffset-based — confirmed (20K cap makes drift risk negligible)Decision 7
8Consistency modelEventual — campaign executes if progress == 'success' OR 'partially_completed'Decision 2
9Partial import failure handlingpartially_completed status — campaign sends to imported contacts; failed pages skippedDecision 9
10Orphan ContactList preventionWrap ContactList + Broadcast creation in ActiveRecord::Base.transaction; enqueue worker AFTER commitDecision 10
11REOPENED 2026-06-30 — CDP identity field / contact resolutionInterim: resolve account_uniq_id by looking up Models::Contact on phone_number/email, not by trusting CDP id directly — pending CDP team confirmationDecision 8
12"Send Now" sending option for segment audience1-hour minimum only applies to execute_type=specific; send_now bypasses the window and executes as soon as contact_list.progress reaches a terminal stateDecision 11
13Prevent duplicate segment campaignsContract rule rejects a new broadcast when an existing message_broadcasts row shares segment_id + message_template_id and is not in a terminal/failed statusDecision 12
14source_type on the campaign itselfAdd message_broadcasts.source_type alongside the existing contact_lists.source_typeDecision 13
15IDOR mitigation on segment_idValidate segment_id belongs to the caller's organization_id before creating a ContactList or listing recipientsDecision 14
16NEW, OPENsegment_version snapshot markerAdd nullable contact_lists.segment_version column now (additive, zero-cost); population source pending CDP confirmationDecision 15

Detail 1.C — Per-Story Change Map

Story #Story titleLayer scopeBE changesAcceptance criteriaRFC anchors
1–8Campaign form / detail UX updatesn/a — covered in FE RFCn/an/an/a
9Create Campaign with Segment as AudienceBE-onlyExtend 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
10Automatically Generate RecipientsBE-onlyNew 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 idrspec: 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
11Minimum Scheduling Window (1 hour) / Send NowBE-onlyDry-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
12Show Customer List from Segment in Campaign DetailBE-onlyNew GET /contact_lists/:id/recipients endpoint backed by Repositories::ContactListRecipients::List; sorted by full_name ASC; paginatedrspec: returns recipients sorted ASC; pagination works; wrong organization_id returns 404§2.4 row 2 · §4.C chunk 6
13Recipient Lists index — source fieldn/a — no new BE neededsource_type already indexed in ES and returned by existing list endpoint. FE can filter/display directly.existing spec passes
14BSUID fallback in recipient generationBE-onlyCreateFromSegmentProcess: when phone array is empty, fall back to account_uniq_id from CDP responserspec: recipient row created with account_uniq_id when phone absent§2.C
15Measure Campaign Usage by Audience SourceBE-onlyAdd Datadog metric create_campaign_audience_type with tag type:segment or type:recipient_list in UserCreateBroadcastDatadog 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


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_id column to contact_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_id is a foreign identifier from an external system (CDP); no FK constraint possible.
  • Option B — New contact_list_segments join 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 UserCreateBroadcast dry-validation contract:
    • Pros: Fails fast before any DB write; consistent with existing send_at validation pattern (L82–87 of user_create_broadcast.rb); returns 422 to FE.
    • Cons: Contract grows larger.
  • Option B — Enforce in CreateFromSegment repository:
    • 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/:id to fetch reachability.whatsapp.count before 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; CreateDirectSelectAll unchanged.
    • Cons: Some duplication of the create-ContactList + enqueue pattern.
  • Option B — Extend CreateDirectSelectAll with source_type branching:
    • 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 id genuinely 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) by phone_number first, then email, and use the matched contact's own account_uniq_id for the recipient row. If no match, fall back to phone_number only (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::AddContactsModelToContactList v2 branch may need a new variant that accepts pre-resolved account_uniq_id alongside CDP-sourced phone_number/custom fields, instead of assuming CDP already supplies it.
  • Customers present in the CDP segment but with no matching Models::Contact row are NOT excluded — they still get a contact_list_recipients row keyed on phone_number (or email, pending Story-9-adjacent PRD confirmation on email-channel eligibility); they simply have no account_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_completed status (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 > 0 AND failed_pages.any?progress = 'partially_completed'
    • total_created > 0 AND 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::ContactList enum gains partially_completed: 'partially_completed'.
  • BroadcastSpecificWorker (wa_cloud) must update its progress check to include 'partially_completed'.
  • contact_lists.error_messages stores 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_now entirely 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_now fires as soon as recipient generation reaches a terminal state, with no additional user-set delay: the 1-hour minimum only ever applied to execute_type=specific (explicit user-picked schedule time); send_now was never subject to it — it's a different execute_type value, 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 on contact_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 /customers endpoint (name, phone, custom_fields, added_at) — no segment-level version/updated-at field has been verified.
  • Option B — Store hub-core's own snapshot timestamp: contact_lists.created_at already 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

PathWhy the agent reads itWhat pattern it teaches
app/core/domains/repositories/contact_lists/create_direct_select_all.rbTemplate for CreateFromSegmentCreates ContactList record + enqueues Sidekiq worker + Datadog metric; sets source_type, progress, version
app/core/workers/create_contact_list_direct_select_all_worker.rbTemplate for new workerAbstractSidekiqWorker; sidekiq_options queue:, retry:; delegates to process repo
app/core/domains/repositories/contact_lists/create_direct_select_all_process.rbTemplate for CreateFromSegmentProcessPagination loop; contact_list.progress transitions; ES index; Datadog
app/core/domains/repositories/contact_lists/add_contacts_model_to_contact_list.rbReuse 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.rbUnderstand existing schema + ES mappingsource_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.rbUnderstand recipient row structurebelongs_to ContactList; has_many ContactExtras
app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rbExtend for segment pathcontract 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.rbMigration dialect patternActiveRecord::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 lookupschema/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 columnsexisting status enum/terminal states; existing columns to extend

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
POST /broadcasts (UserCreateBroadcast)extendedAdd optional segment_id + estimated_recipient_count params + 1-hour schedule ruleChat-2
GET /contact_lists/:id/recipientsnew-with-justificationNo 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)reusedExisting CDP S2S endpoint from Technical Design API docCDP team

Patterns to Follow

ConcernPattern in repoReference fileDeviation?
Sync create + async worker entry pointRepositories::ContactLists::CreateDirectSelectAllcreate_direct_select_all.rb:3none
Sidekiq worker shapeCreateContactListDirectSelectAllWorkercreate_contact_list_direct_select_all_worker.rb:3none
Pagination + bulk import orchestratorRepositories::ContactLists::CreateDirectSelectAllProcesscreate_direct_select_all_process.rb:3yes — data source is CDP API, not ES
Version 2 bulk import (ContactListRecipient)AddContactsModelToContactList version 2 branchadd_contacts_model_to_contact_list.rb:31yes — input is CDP response, not contact_id array
Dry-validation contract extensionUserCreateBroadcast contract blockuser_create_broadcast.rb:8new rule(:segment_id) added
Datadog metricServices::Datadog::CaptureCustomMetriccreate_direct_select_all_process.rb:58none — same service, new metric name
Feature flag checkServices::Preference.new.enabled?(:flag)create_direct_select_all.rb:22none
Migration formatAddVersionToContactLists20250505151110_add_version_to_contact_lists.rb:3none

Reading Order for the Agent

  1. app/core/domains/repositories/contact_lists/create_direct_select_all.rb — understand the sync entry point pattern to clone.
  2. app/core/workers/create_contact_list_direct_select_all_worker.rb — understand worker boilerplate.
  3. app/core/domains/repositories/contact_lists/create_direct_select_all_process.rb — understand the orchestrator loop to adapt.
  4. app/core/domains/repositories/contact_lists/add_contacts_model_to_contact_list.rb — read version 2 branch (lines 31–60) to understand bulk import.
  5. app/core/domains/models/contact_list.rb — confirm existing columns (source_type, version, progress) and ES mapping.
  6. app/core/domains/models/contact_list_recipient.rb — confirm recipient row columns.
  7. app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb — read full file to understand where to add segment path.
  8. database/core/db/migrate/20250505151110_add_version_to_contact_lists.rb — migration format.
  9. database/core/db/migrate/20260504100000_add_ext_bsuid_columns_to_contact_list_recipients.rb — bsuid column reference.

Source Verification (anti-hallucination)

Anchor / pattern / contractVerified byEvidence
create_direct_select_all.rbreadsource_type: 'contacts' at L19; CreateContactListDirectSelectAllWorker.perform_async at L30; Builders::ContactList.new(contact_list).build at L34
create_contact_list_direct_select_all_worker.rbreadsidekiq_options queue: :create_contact_list_direct_select_all, retry: 3 at L4; delegates to CreateDirectSelectAllProcess at L7
create_direct_select_all_process.rbreadloop do pagination at L19; contact_list.progress = 'success' at L47; .__elasticsearch__.index_document at L56; CaptureCustomMetric at L58
add_contacts_model_to_contact_list.rbreadVersion 2 branch at L31: build_contact_list_recipients; Models::ContactListRecipient bulk import at L32
Models::ContactListsource_type existsreadL37 as_indexed_json includes :source_type; ES mapping indexes it at L102
Models::ContactListprogress enumreadL14–18: enum progress: { processing: 'processing', success: 'success', failure: 'failure' }
Models::ContactListRecipientreadL3: class Models::ContactListRecipient < Models::AbstractModel; L6: has_many :contact_extras
UserCreateBroadcast — contract + resultreadcontract params block at L9–52; validate_contact_list at L198; validate_broadcast_quota at L230
Migration patternreadAddVersionToContactLists uses column_exists? guard + add_column at L6–7
CDP S2S endpointCDP API doc readGET /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.

  1. POST /broadcasts returns 200 as soon as the ContactList + MessageBroadcast rows 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.
  2. 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.
  3. On success or partial success (progresssuccess or partially_completed, Decision 9), the recipient list is complete (or complete-minus-failed-pages) and campaign execution proceeds at send_at (or immediately for send_now, Decision 11).
  4. On failure (progress == 'failure', zero contacts imported after 3 Sidekiq retries): the campaign row still exists — it is not deleted — but BroadcastSpecificWorker skips execution because progress is not in ('success', 'partially_completed'). The campaign surfaces as failed/blocked in the FE via the existing contact_list.progress field; 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.
  5. There is no user-facing retry action in Phase 1 — a fully failed segment campaign must be recreated from scratch (new POST /broadcasts call); 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:

idorganization_idnamesource_typesegment_idprogressversion
cl_abcorg_xyzLoyal WA Customerssegment683ab...success2
cl_deforg_xyzQ2 Promo LeadscontactsNULLsuccess2

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 valueVisibilityRetentionRestore semanticsTransitions allowed
processingshown in recipient list index as "Processing"until finished_at setn/a — transient→ success, → failure
successshown as "Uploaded"standard (soft-delete on parent)allowedterminal
failureshown as "Failed"standardn/aterminal

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)

FieldValue
MethodPOST
Pathexisting /broadcasts endpoint
AuthN/AuthZIAG JWT + user must have customers_segment_view permission
Idempotencynone (same as existing broadcast creation)
Versioningadditive — 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:

ConditionHTTPError message
send_at < now + 1h with segment_id422"must be at least 1 hour from now for segment campaigns"
Insufficient balance422"Your broadcast message requires {total_cost} balance..."
execute_type=campaign_plan with segment_id422"recurring campaigns cannot use segment as audience"
segment_id missing AND contact_list_id missing422existing "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

FieldValue
MethodGET
Path/contact_lists/:id/recipients
AuthN/AuthZIAG JWT + organization_id ownership check
Idempotencyread-only
Versioningnew endpoint
Reuse?new-with-justification — no existing endpoint returns per-row recipients; existing /contact_lists/:id only returns counts

Query params:

ParamTypeDefaultNotes
pageint1
per_pageint10max 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:

ConditionHTTPMessage
contact_list not found or wrong org404"Contact list not found"
Unauthenticated401existing IAG response

Inbound webhooks

N/A — no webhooks for this feature.


Detail 2.A — Data Integrity Matrix

Write pathTransaction scopePartial failure behaviorIdempotency key + TTLConsistency modelDuplicate handlingStale-read
CreateFromSegment (ContactList creation)single DB write + enqueueIf ContactList save fails → return Failure; worker never enqueuednone (new record each time)strongn/a — new recordn/a
UserCreateBroadcast segment pathContactList 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.nonestrong (within transaction)n/an/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)eventualon_duplicate_key_update backed by unique indexes aboven/a
ContactList.progress updatesingle UPDATEIf update fails → ES index stale; Sidekiq retry re-processesn/astrongn/aES shows stale processing until next index

Detail 2.B — Concurrency Collision Map

ResourceWritersCollision scenarioResolutionOn failure
contact_lists.progressCreateFromSegmentProcess workerTwo 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 enhancementSecond write wins — acceptable
contact_list_recipients bulk importCreateFromSegmentProcess (Sidekiq retry)Retry re-imports same CDP page → duplicate rowson_duplicate_key_update on activerecord-importIdempotent — no error

Detail 2.C — Async Job / Event Consumer Spec

JobTriggerInput shapeRetryDLQConcurrencyIdempotency keyPer-message timeoutPoison-message handling
CreateRecipientFromSegmentWorkerCreateFromSegment.callperform_asyncJSON: { contact_list_id:, segment_id:, organization_id:, segment_name: }3 attempts, Sidekiq default backoffSidekiq dead queueSidekiq 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 valueContactListRecipient variable typeStorage format
single_line_textTextas-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)Textas-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)
dropdownTextas-is string
multiple_selectTextjoin 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
dateText (raw timestamp, not normalized to YYYY-MM-DDcorrected 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.
urlURLas-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_uploadexcludedskip
signatureexcludedskip
gpsexcludedskip

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_email is built once per page via a single batched query — see Decision 8 consequences (avoid N+1: one Models::Contact query per 100-row page, not per customer).

CDP response field mapping (from official CDP API doc GET /api/v1/segments/:id/customers):

CDP response fieldContactListRecipient field
namefull_name
phone[0]phone_number (first element of array)
emailused 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 name REOPENED 2026-06-30 — do not use directly. See Decision 8: resolve account_uniq_id via Models::Contact lookup on phone/email instead
custom_fields[].keycontact_extras.extra hash key
custom_fields[].valuecontact_extras.extra hash value (after type mapping)

Decision 7 (resolved): Offset-based pagination confirmed — page/per_page max 100. Cursor-based pagination is NOT used. See §Technical Decisions → Decision 7. Decision 8 (REOPENED 2026-06-30): The idaccount_uniq_id mapping above is no longer trusted as-is. See §Technical Decisions → Decision 8.

Detail 2.D — Responsibility Boundary Matrix

StepOwning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. User selects segment in campaign formFE (Chat-2)User interactionAPI call POST /broadcasts with segment_idFE validationStory 6
2. Validate schedule + balancehub-core (Chat-2)POST /broadcasts422 or continuereturn FailureStories 9, 11
3. Create ContactList (progress=processing)hub-core (Chat-2)Passed validationContactList row + worker enqueuedFailure → no ContactListStory 10
4. Fetch CDP segment customers (paginated)hub-core workerWorker pickupcontact_list_recipients rowsCDP error → retry 3× → failureStory 10
5. Mark ContactList progress=success/failurehub-core workerEnd of loopES re-indexed; Datadog metricrescue block sets failureStory 10
6. Execute campaign at scheduled_athub-core / wa_cloudBroadcastSpecificWorker firesMessages sent via Meta APIchecks progress == 'success'; skips if failureStory 9
7. Show recipients in Campaign Detailhub-core APIFE GET requestPaginated recipient list response404 if not foundStory 12

Detail 2.E — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
ContactListprogress (processing/success/failure)processing at creationCreateFromSegmentProcess workerexisting list endpoint + ESUntil worker completes (≤ 1hr budget)
ContactListfinished_atnullCreateFromSegmentProcess on completionexisting detail endpoint
ContactListcontacts_count (computed from contact_list_recipients count)0bulk importES index via contacts_count methodUntil 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 /broadcasts with 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_status Datadog metric with status:success / status:failure tags (new — mirrors upload_contact_from_direct_select_all_contact_status at create_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:failure rate > 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_number must 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_id belonging to another org (reviewer feedback, 2026-06-30 — see Decision 14); CDP credential leakage; SSRF via segment_id.
  • AuthN/AuthZ: organization_id enforced on every ContactList and ContactListRecipient query. segment_id ownership is additionally verified against organization_id before CreateFromSegment runs (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_id is a string — validate UUID format; no URL construction from segment_id directly (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::MessageBroadcast row records user_id + organization_id + contact_list_id for all campaign creations.
  • ISO 27001: CDP customer properties may include PII (name, phone, email, DOB). Stored in contact_extras.extra (JSONB). Existing encryption via LOCKBOX should be evaluated for contact_extras.extra — see Open Q #5.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)Permitted methodsTenant scopeAdditional constraintAudit trail
Agent / Supervisor / Admin (Qontak One + CDP module + customers_segment_view)POST /broadcasts (segment path)POSTown org onlymust have customers_segment_view IAG permissionmessage_broadcasts row
Agent / Supervisor / AdminGET /contact_lists/:id/recipientsGETown org onlycontact_list must belong to orgn/a
System (worker)CDP S2SGETorg scoped via organization_id in ContactListBasicAuth credentialscontact_lists.progress transition

Detail 3.A — Failure Mode & Retry Catalog

External callTimeoutRetriesCircuit breakerDLQ + retentionCaller 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 retriesnone (Phase 1 — add if CDP proves unstable)Sidekiq dead queuecontact_list.progress = 'failure'; campaign blocked at execution

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAudit trailUser-visible?
Balance insufficientUserCreateBroadcast contract422; ContactList NOT createdn/ayes — 422 message
send_at < now + 1h with segmentUserCreateBroadcast contract rule422n/ayes — 422 message
execute_type=campaign_plan + segment_idUserCreateBroadcast contract rule422n/ayes — 422 message
0 eligible customers from CDP (after all Sidekiq retries)CreateFromSegmentProcess rescue (all retries exhausted)progress=failurecontact_lists.progressindirect (campaign blocked at execution)
Some CDP pages fail, some succeedCreateFromSegmentProcess per-page rescue (Decision 9)progress=partially_completed; campaign executes with imported contactscontact_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 retriescontact_lists.progressindirect

Reviewer note (2026-06-30): a true 0-eligible-customers result should be rare in normal operation, since estimated_recipient_count is 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: mark failure immediately, same as documented above.

Detail 3.B — Error Response Catalog

EndpointError codeHTTP statusMessageWhen it occursUser-facing?
POST /broadcastsINVALID_SCHEDULE422"must be at least 1 hour from now for segment campaigns"send_at < now + 1hr with segment_idyes
POST /broadcastsINSUFFICIENT_BALANCE422"Your broadcast message requires {cost} balance..."balance check failsyes
POST /broadcastsRECURRING_NOT_ALLOWED422"recurring campaigns cannot use segment as audience"execute_type=campaign_plan + segment_idyes
GET /contact_lists/:id/recipientsNOT_FOUND404"Contact list not found"wrong id or orgno (developer-facing)

Detail 3.C — Compliance & Data Governance

FieldClassificationLegal basisRetentionEncryption (rest + transit)Access auditRight-to-delete
contact_list_recipients.full_namePII (name)UU PDP — legitimate interest for campaign sendingStandard retention (deleted with parent ContactList)transit: HTTPS; rest: see Open Q #5organization_id scopesoft-delete on ContactList
contact_list_recipients.phone_numberPII (phone)UU PDP — campaign consentsametransit: HTTPS; rest: see Open Q #5organization_id scopesoft-delete on ContactList
contact_extras.extraPII (may include email, DOB, etc.)UU PDP — campaign variablessametransit: HTTPS; rest: see Open Q #5organization_id scopesoft-delete on ContactList

4. Backwards Compatibility and Rollout Plan

Compatibility

  • POST /broadcasts endpoint: additive — new optional params segment_id and estimated_recipient_count. Existing callers who omit these params are entirely unaffected (the if segment_id.present? branch is only taken when segment_id is provided).
  • Existing contact_lists data: segment_id column added as nullable — no existing rows are affected.
  • source_type column: existing value 'contacts' unchanged; 'segment' is a new string value.

Rollout Strategy

  • Feature flag: send_campaign_with_segment (new — register via Services::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:
    1. Deploy migration (add segment_id to contact_lists) → add nullable column, no downtime.
    2. Deploy hub-core code with flag OFF.
    3. Enable flag for internal orgs → smoke test.
    4. Enable flag for 5% of Qontak One orgs with CDP module → monitor.
    5. Enable flag 100%.
  • Rollout stages:
StageAudienceGo/no-go evidence
InternalMekari internal orgsupload_contact_from_segment_status:success > 0; no error spike
5%Qontak One + CDP module orgsfailure rate < 2%; p99 worker completion < 30min
100%All Qontak One + CDP module orgsfailure rate < 2% sustained 24h
  • Rollback trigger: status:failure rate > 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 processing or failure — campaign execution will check progress and skip if not success. No data corruption.
  • PIC: Hilmi Dama (lead), Burhanudin Hakim (backup).

Detail 4.A — Configuration Contract

Env var / config / flagTypeDefaultRequiredProvisionerSecret?
send_campaign_with_segment (Flipper flag)booleanfalseyesChat-2 at deployno
CDP_SEGMENT_CLIENT_BASE_URLstringhttps://contact-service.qontak.netyesPlatform/Infrano
CDP_SEGMENT_CLIENT_USERNAMEstringyesPlatform/Infra (Vault)yes
CDP_SEGMENT_CLIENT_PASSWORDstringyesPlatform/Infra (Vault)yes
CDP_SEGMENT_CLIENT_TIMEOUT_SECONDSinteger30noChat-2no

Detail 4.B — Test Plan

LayerCommand (source)What it must prove
Unitbundle 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
Unitbundle exec rspec app/core/domains/repositories/contact_lists/create_from_segment_process_spec.rbCreateFromSegmentProcess fetches CDP pages, maps properties, creates recipients, marks progress
Unitbundle exec rspec app/core/workers/create_recipient_from_segment_worker_spec.rbWorker delegates to process repo; Sidekiq retry semantics
Integrationbundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rbExisting 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)
Contractbundle exec rspec app/apps/broadcast_service/No regression in broadcast service
Full suitebundle exec rspec (source: AGENTS.md)Full suite green
Lintbundle exec rubocop --no-color (source: AGENTS.md)0 offenses
Securitybundle 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

OrderChunkFiles to modify/createCommands to runAcceptance criteria
1DB 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.rbbundle exec rake db:migratecontact_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.
2CDP service clientcreate app/apps/broadcast_service/services/cdp/segment_client.rbbundle exec rubocop app/apps/broadcast_service/services/cdp/Service class responds to fetch_customers; Faraday timeouts configured; BasicAuth from env
3CreateFromSegment entry point repositorycreate app/core/domains/repositories/contact_lists/create_from_segment.rb + specbundle exec rspec app/core/domains/repositories/contact_lists/create_from_segment_spec.rbHappy path: ContactList created with source_type='segment', segment_id, version='2', progress='processing'; worker enqueued; spec covers wrong organization_id
4DB migration: idempotency indexes on contact_list_recipients (deferred from Chunk 1, 2026-07-07 — Open Q #4 reopened) + CreateRecipientFromSegmentWorker + CreateFromSegmentProcessblocked on Open Q #7 for the identity-resolution logiccreate 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 + specsbundle 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.rbBoth 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"
5Extend UserCreateBroadcast for segment pathmodify app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rbbundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rbSegment 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
6GET /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.rbhub-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.
7Feature flag registrationregister send_campaign_with_segment flag via Services::Preference.new.add(...) in a rake task or migration commentbundle exec rubocop --no-colorFlag exists in DB/Flipper after task runs
8Full suite + lint + security scanno new filesbundle exec rubocop --no-color && bundle exec rspec && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -qAll green, 0 Rubocop offenses, no new Brakeman HIGH findings

Detail 4.D — Verification & Rollback Recipe

Pre-merge verification commands (in order):

  1. bundle exec rubocop --no-color
  2. bundle 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.rb
  3. bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb
  4. bundle exec rspec (full suite)
  5. bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q

Post-deploy verification signals:

  • Datadog metric upload_contact_from_segment_status with status:success appearing > 0 after first test campaign creation.
  • Sidekiq queue create_recipient_from_segment depth returns to 0 after test campaigns complete.
  • contact_lists rows with source_type='segment' have progress='success' and non-null finished_at.

Rollback recipe (in order):

  1. Toggle send_campaign_with_segment flag OFF via Services::Preference.new.disable(:send_campaign_with_segment).
  2. Confirm Sidekiq queue create_recipient_from_segment drains or jobs fail gracefully (no new jobs enqueued after flag OFF).
  3. If migration must be rolled back: bundle exec rake db:rollbacksegment_id column drops; no existing data affected (column was nullable).
  4. Confirm Datadog metric upload_contact_from_segment_status stops 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_recipients rows + contact_extras rows per segment campaign. At 100 campaigns/day = 2M rows/day peak — evaluate against current write headroom. Index on contact_list_id already 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

  1. Open Q #1 — CDP pagination protocol CLOSED (Decision 7): Offset-based pagination confirmed — page/per_page max 100. Cursor-based pagination is NOT used. 20K cap makes drift negligible. See §Technical Decisions → Decision 7.

  2. 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/customers latency in staging before rollout.

  3. Open Q #3 — Orphan ContactList on Broadcast creation failure CLOSED (Decision 10): ContactList + Broadcast creation are now wrapped in ActiveRecord::Base.transaction. Worker is enqueued only after the transaction commits. No orphan ContactList is possible if Broadcast creation fails. See §Technical Decisions → Decision 10.

  4. 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_phone on (contact_list_id, phone_number) WHERE phone_number IS NOT NULL and idx_clr_idempotency_bsuid on (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 until CreateFromSegmentProcess (Chunk 4) is implemented, and Chunk 4's bulk import must pass an explicit conflict_target: referencing them for on_duplicate_key_update to work (Postgres requires a matching unique constraint for ON CONFLICT (columns) to be valid SQL at all). Ship this migration alongside Chunk 4 instead. See §2.3 DDL.

  5. Open Q #5 — PII encryption for contact_extras.extra (still open): CDP custom fields stored in contact_extras.extra (JSONB) may include email, DOB, etc. Verify if LOCKBOX encryption applies to contact_extras.extra for existing flows. If not, this is a compliance gap per UU PDP. Infosec approver sign-off required before 100% rollout.

  6. Open Q #6 — CDP API field name discrepancy REOPENED 2026-06-30 (was CLOSED 2026-06-22): The id (UUID) → account_uniq_id mapping is no longer trusted — reviewer feedback states CDP segmentation only carries phone/email per customer. Superseded by Decision 8 (REOPENED) and tracked going forward as Open Q #7 below. Do not treat this as resolved.

  7. 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 batched Models::Contact lookup on phone/email per 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.

  8. 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 suggested x but this was not a final confirmation; (b) whether a url-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 in contact_extras.extra and should be confirmed before Chunk 4 ships to avoid a follow-up data migration.

  9. 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_extras rows 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.

  10. Open Q #10 — Segment ownership validation mechanism (blocking Decision 14 implementation, non-blocking for other chunks): Decision 14 requires verifying segment_id belongs to the caller's organization_id before 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 when organization_id is passed on the request. Action required: confirm with CDP team before Chunk 5 (UserCreateBroadcast extension) is finalized.

  11. 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 blocks CreateFromSegmentProcess (Chunk 4) from actually writing a meaningful value — until resolved, the column stays NULL.

  12. 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.

  13. 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.

  14. Known limitation — partially_completed campaign execution: BroadcastSpecificWorker (wa_cloud app, outside hub-core) currently checks contact_list.progress == 'success' before broadcasting. This check must be updated to progress 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

DateComment(s) FromAction Item(s)
2026-06-18Hilmi DamaInitial RFC draft. Open Questions #1–6 need resolution before chunk 2 (CDP client) can start.
2026-06-22Hilmi DamaResolved 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-01Hilmi 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-07Hilmi 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::SegmentClient uses page/per_page (max 100). No cursor logic needed.
  • Decision 9 (was Open Q #3): Per-page error handling with partially_completed status. Partial import proceeds; campaign executes on success OR partially_completed. Zero-contact case raises for Sidekiq retry.
  • Decision 10 (was Open Q #3 orphan risk): ActiveRecord::Base.transaction wraps ContactList + Broadcast creation. CreateFromSegment accepts skip_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 — idaccount_uniq_id is retracted pending CDP team confirmation of the segment-members response shape. Blocks Chunk 4. See Open Q #7.
  • Route ownership: GET /contact_lists/:id/recipients route 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 existing contact_list.progress check (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_version data 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 check progress 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.
1 → 3 → 2 → [Open Q #7] → 4 → [Open Q #10] → 5 → 6 → 7 → 8
  1. DB migration (Chunk 1) — no dependencies; now also adds message_broadcasts.source_type/segment_id (Decision 13)
  2. Feature flag registration (Chunk 7) — can run any time after Chunk 1
  3. CDP service client (Chunk 2) — requires CDP credentials in staging
  4. Resolve Open Q #7 with the CDP team — blocking, do this before Chunk 4 implementation, not during it
  5. CreateFromSegment + worker + process (Chunks 3–4) — Chunk 3 can start immediately; Chunk 4's identity-resolution logic requires Open Q #7 resolved
  6. Resolve Open Q #10 with the CDP team — blocking, do this before finalizing the IDOR check in Chunk 5
  7. UserCreateBroadcast extension (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 it
  8. GET /contact_lists/:id/recipients repository + interactor (Chunk 6) — independent
  9. wa_cloud BroadcastSpecificWorker update (out-of-scope for hub-core — coordinate separately)
  10. Full suite + lint + security scan (Chunk 8)

Optional: hand off to rfc-reviewer for a second-pass score to confirm the updated RFC reaches SHIP verdict.