Skip to main content

RFC Review: WhatsApp Campaign — Send Campaign with Segmentation (BE Phase 1)

Definitive Review (2026-06-22) — Third-pass assessment of the fully revised RFC. Covers Decisions 7, 9, 10 and all closed Open Questions. Score recalculated from scratch against the complete rubric.

Executive Summary

  • Overall Score: 8.0/10
  • Rating: Strong
  • RFC Type: backend
  • Sub-Type: new-feature
  • Assessment Confidence: High
  • Applied Caps/Gates: None — all categories above cap thresholds
  • Implementation Readiness Verdict: PROCEED with notes — 5 concrete gaps; none block Chunks 1–8; 3 must close before production rollout
  • Report Path: rfc-review-report.md
  • RFC Author: Hilmi Dama | Reviewed: 2026-06-22

An AI agent can read this RFC and produce correct implementation code for approximately 90% of the feature without asking the author a question. All 10 decisions are closed; the PRD traceability matrix is bidirectional and complete; Decision 10's transaction wrapping and skip_enqueue: true pattern prevent the orphan ContactList problem with a directly implementable code sketch; the Source Verification table cites 10 real file anchors with specific line numbers. Five gaps remain: (1) Faraday retry backoff interval unspecified — agent generates interval: 0 (CDP hammered on failure); (2) organization_id source (JWT claims vs. request body) unspecified — potential tenant-hopping gap; (3) contact_extras.extra PII stored in plaintext JSONB (Open Q #5, gated by Infosec before 100% rollout); (4) wa_cloud BroadcastSpecificWorker deploy coordination lacks a ticket or owner — partially_completed campaigns silently skip until that team ships; (5) ES index_document failure after progress UPDATE has no rescue or alert. Gap 1 and Gap 2 each take five minutes to specify and should be added before the agent starts Chunks 3 and 6 respectively.


Quick Verdict

Why this RFC can be implemented agentically:

  • All 10 decisions resolved with alternatives rejected, rationale grounded in codebase reality, and reversibility stated. Agent makes zero architectural choices that the author should have made.
  • Decision 10 code sketch (§Technical Decisions) shows the exact ActiveRecord::Base.transaction + skip_enqueue: true + post-commit perform_async — no guessing on the hardest atomicity boundary in the feature.
  • §2.0 Reading Guide: 9-file reading order + Patterns to Follow table + 10-row Source Verification table with line numbers. Agent reads real files before writing; cannot hallucinate patterns.

Why this RFC will cause agent guessing or rework:

  • Faraday retry backoff is "3 retries via Faraday retry middleware" — no interval or backoff_factor specified. Agent generates interval: 0 (default), hammering CDP three times in rapid succession on each page failure.
  • organization_id source (JWT claims vs. request body) is not stated anywhere in the POST /broadcasts contract extension. Agent infers from existing code; if it infers wrong, a malicious user can supply a different tenant's org_id.
  • Open Q #5 (at-rest PII encryption for contact_extras.extra) is classified "non-blocking for development" — agent generates unencrypted JSONB storage for email/DOB/custom CDP fields. If Infosec requires LOCKBOX, the retrofit touches bulk import, query, and ES indexing paths.

PRD → RFC Traceability Matrix

Score delta from first pass

What changedImpact
Story 15 metric gap filled (create_campaign_audience_type added to §1.C + §2.4)Forward coverage now complete
Stories 9–14 all mapped in §1.C with acceptance criteriaUnchanged — already strong
PRD ElementRFC SectionCoverage
Story 1–8 (FE UX)§1.A — n/a FE RFCFull — explicitly out of scope
Story 9 — Create Campaign with Segment§2.4, §4.C Chunk 5, §1.C row 9Full
Story 10 — Async generate recipients§2.C, §2.3 DDL, §4.C Chunks 3–4Full
Story 11 — 1-hour schedule window§2.4 contract rule, §4.C Chunk 5Full
Story 12 — Campaign Detail customer list§2.4 GET endpoint, §4.C Chunk 6Full
Story 13 — Recipient Lists source field§1.C — n/a (source_type already indexed)Full — justified no-op
Story 14 — BSUID fallback§2.C BSUID sketch, §2.C field mappingFull
Story 15 — Measure campaign usage§1.C, §2.4 create_campaign_audience_typeFull — gap closed in this revision
Phase 2 batching§1 Out of ScopeFull — explicitly deferred
CDP field mapping§2.C field mapping tableFull
RBAC segment view§3 SAS, §3 Role × Endpoint matrixFull

Summary: 11 of 11 PRD items fully covered. 0 partial. 0 missing. 0 RFC decisions without PRD justification.


Scorecard

Backend Scorecard

CategoryScoreEvidence-Based Rationale
PRT — PRD Traceability9.0Bidirectional matrix §1.A covers 11/11 PRD items. Per-story change map §1.C with acceptance criteria. Reverse mapping (3 RFC-added decisions) correctly labeled engineering additions, not scope creep. Deducted 0.5 vs top: Decision 9 (partially_completed) is RFC-introduced behavior not in PRD; should carry a one-line "RFC extension — not PRD-driven" note rather than appearing without comment in §1.B.
TDC — Technical Decisions9.010 decisions with full ADR blocks (context/options chosen/rationale/consequences/reversibility). All alternatives rejected with reasons grounded in codebase or infra reality. Decision 8 (CDP field mapping) lacks standalone ADR block — embedded in closed Q#6 and §2.C; content is complete, presentation is a minor gap. No dangling "TBD" anywhere.
DMS — Data Model & Schema8.0DDL section specifies all new changes: segment_id VARCHAR NULL + partial index, 2 idempotency partial unique indexes with correct WHERE clauses. ERD shows full 3-table shape with PII classification. Per-status lifecycle table and cardinality estimate present. Gaps: contact_list_recipients full DDL not shown (agent reads from ERD + existing model); finished_at/error_messages not explicitly confirmed as existing columns in DDL section (though ERD shows them); error_messages column type (text vs jsonb) not specified in DDL.
ACV — API Contract & Versioning8.0POST /broadcasts: new optional params with typed dry-validation code, example request + response JSON, 4-error taxonomy, explicit additive backward-compat. GET /contact_lists/:id/recipients: params with types/defaults/max, response with pagination, 2-error taxonomy. Gaps: per_page > 200 error response missing; segment_name max length unspecified; estimated_recipient_count has no upper bound (gt?: 0 only); no rate limit on either endpoint; IAG middleware class name not given; error response envelope shape not standardized.
DIC — Data Integrity & Consistency8.5Full Data Integrity Matrix §2.A: all write paths covered with transaction scope, partial failure behavior, idempotency key, consistency model, duplicate handling. Decision 10: ActiveRecord::Base.transaction + skip_enqueue: true + post-commit enqueue — code sketch is implementable. Two partial unique indexes back on_duplicate_key_update. Gaps: worker enqueue fails after transaction commit (Redis blip) → ContactList stuck in processing indefinitely, no recovery mechanism or alert; ES index_document failure after progress UPDATE has no rescue block or retry — Sidekiq re-runs the full import unnecessarily.
FMC — Failure Mode & Retry Coverage8.0CDP SegmentClient: 30s timeout (open 5s/read 30s), Sidekiq dead queue. Per-page rescue with failed_pages tracking (Decision 9). Branch & Skip Catalog §3.A.1: 6 branches. Error response catalog §3.B: 4 entries complete. Zero-contacts case raises for Sidekiq retry. Gap: Faraday retry backoff interval not specified — "3 retries via Faraday retry middleware" only; default interval: 0 means immediate retry hammering CDP. ES index_document failure has no handler (agent generates bare call). No circuit breaker (Phase 1 deferral noted — acceptable).
CSS — Concurrency & Scaling7.0Concurrency collision map §2.B: 2 points with resolution. Performance target: API p99 < 500ms, worker < 30min. Correctness-critical concurrency (transaction + idempotency) well-specified. Gaps: no concurrency limit on create_recipient_from_segment Sidekiq queue — at 25 concurrent workers × 200 pages = 5,000 concurrent CDP connections during peak. No per-org or global CDP rate limit. No backpressure strategy if CDP is slow and jobs pile up.
SAS — Security & Authorization7.5Threat model names 3 attackers. SSRF mitigated via path template. ENV.fetch for credentials (not logged). PII excluded from logs. Role × Endpoint matrix. organization_id scoped on all queries. Gaps: organization_id source unspecified — JWT claims or request body? If request body without JWT cross-validation, tenant hopping possible. segment_id UUID format validation mentioned but exact rule not given. segment_name max length unspecified. estimated_recipient_count no upper bound. No rate limiting.
MRP — Migration & Rollout Plan8.5Feature flag send_campaign_with_segment default OFF, provisioner specified, 3-stage rollout with per-stage go/no-go gates. Rollback trigger (>10% failure rate, p99 > 45min) — specific. Rollback: toggle flag + nullable column drop (no data loss). Config contract §4.A: 5 env vars with types/defaults/secrets. PIC named. Gaps: wa_cloud BroadcastSpecificWorker deploy order relative to hub-core Phase 1 not specified — if hub-core ships partially_completed before wa_cloud update, those campaigns silently never execute.
OBS — Observability Definition8.0Metric: upload_contact_from_segment_status (status tags). create_campaign_audience_type metric (type tags). Sidekiq queue depth alert (>100). PagerDuty at >5% failure rate/10min. SLO: 99% within 30min. Structured log fields + PII exclusion rule. Gaps: trace span names not specified; no RED metric for POST /broadcasts API itself; Datadog dead queue alert absent; dashboard location is vague ("existing Sidekiq dashboard + new panel").
SBC — Service Boundary & Coupling8.5hub-core owns all new code. CDP isolated via Services::Cdp::SegmentClient. Route split: hub-service owns route, hub-core owns repo + interactor only. wa_cloud BroadcastSpecificWorker cross-team coordination explicitly identified. Per-service responsibility mermaid diagram present.
CPA — Pattern Alignment9.5Exceptional. 9-file reading order. Patterns to Follow table: 7 patterns with reference files + deviation annotations. Source Verification table: 10 rows with specific evidence (function names, line numbers, quoted identifiers — e.g., L14–18: enum progress: { ... }). AbstractIteractor typo + AbstractSidekiqWorker base class correctly used in worker sketch. No silent pattern departures.
CDG — Compliance & Data Governance6.5CDG Active — PII triggered. §3.C classifies full_name, phone_number, contact_extras.extra; cites UU PDP; states soft-delete retention; confirms HTTPS transit. Gaps: at-rest encryption for contact_extras.extra explicitly Open Q #5 (unresolved — agent generates plaintext JSONB for email/DOB); right-to-delete via soft-delete doesn't guarantee physical deletion (UU PDP Art. 33–34 may require it); CDP contact-service.qontak.net jurisdiction not analyzed; PII SELECT audit logging not specified; "standard retention" period undefined.

Resource & Cost Advisory

Detail §4.E: no new pods; up to 2M rows/day at 100 campaigns/day (evaluate DB write headroom); S2S intra-Mekari (no external egress); ~40MB storage per campaign — consistent with existing Direct Select All loads.


Decision Closure Assessment

Decision Index

#DecisionStatusCritical Gaps
1segment_id column on contact_listsResolvedNone
2Async Sidekiq workerResolvedNone
3CDP integration via Services::Cdp::SegmentClientResolvedFaraday retry backoff interval unspecified
41-hour schedule enforcement in UserCreateBroadcast contractResolvedNone
5Balance validation with FE-provided estimated count + 10% bufferResolvedNone
6New CreateFromSegment repository (clone CreateDirectSelectAll)ResolvedNone
7Offset-based CDP pagination (page/per_page max 100)ResolvedNone
8Consistency model — eventual; success OR partially_completedPartialNo standalone ADR block; wa_cloud BroadcastSpecificWorker update has no ticket or owner
9partially_completed status with per-page error handlingResolvedMinimum success threshold for partially_completed → execute not specified
10Transaction wrapping ContactList + Broadcast; worker enqueued after commitResolvedRedis-blip-after-commit leaves ContactList stuck in processing — no recovery mechanism

Aggregate: 9 of 10 Resolved, 1 Partial, 0 Dangling.


Decision 3 — CDP integration via Services::Cdp::SegmentClient (Resolved with implementation gap)

Status: Resolved — one implementation gap in the Faraday spec.

What was decided: New Services::Cdp::SegmentClient using Faraday/Net::HTTP, S2S BasicAuth, timeout open_timeout: 5, read_timeout: 30, "3 retries via Faraday retry middleware."

Alternatives considered: Kafka/RabbitMQ rejected (CDP doesn't publish); CDP SDK rejected (no Ruby SDK). Both rejections grounded in infra reality. ✓

Grounding: Faraday pattern referenced conceptually; no existing internal HTTP client file cited as a clone template. Agent must infer Faraday setup conventions from scratch.

Interface: fetch_customers returns { customers: [...], total_pages: N }. CDP response field mapping complete in §2.C.

Failure handling: Per-page rescue in CreateFromSegmentProcess catches all exceptions. Sidekiq retries job 3×.

Gap: "3 retries via Faraday retry middleware" specifies count but not interval. Faraday's default interval: 0 means all three retries fire immediately. During a CDP outage, the worker generates 3 rapid requests per page before failing — for 200 pages over 3 Sidekiq retries = up to 1,800 rapid-fire CDP requests in seconds.

Suggested resolution: Add to SegmentClient spec in §2.C or Decision 3:

retry_options = {
max: 3,
interval: 0.5, # 0.5s → 1.0s → 2.0s
backoff_factor: 2,
max_interval: 10,
retry_statuses: [429, 500, 502, 503, 504]
}

Open question: What is CDP's stated rate limit (req/s or req/min)? This determines the minimum safe interval.


Decision 8 — Consistency model (Partial)

Status: Partial — content complete, presentation and cross-team coordination gap.

What was decided: Campaign execution proceeds when contact_list.progress IN ('success', 'partially_completed'). Eventual consistency; worker has 1-hour window.

Gap 1 — Presentation: Decision 8 in §1.B summary table references "Decision 2" rather than its own ADR block. Agent must hold two sections in mind. Minor but creates ambiguity on first read.

Gap 2 — Cross-team coordination: BroadcastSpecificWorker (wa_cloud) update is listed in §5 Known Limitation #9 and §7 non-blocking items, but has no Jira ticket, no owner, and no delivery date. Until it ships, partially_completed campaigns are silently skipped at execution — worse UX than failure because the user sees no error, the campaign appears sent, but 0 messages are delivered. This must be treated as a prerequisite for Phase 1 reaching 100% rollout, not just a "known limitation."

Suggested resolution: Add a standalone #### Decision 8 ADR block. Add a ticket reference and owner to §5 Known Limitation #9. Move the BroadcastSpecificWorker update from "non-blocking" to "required before 100% rollout" in §7.

Open question: Has the wa_cloud team acknowledged this? Is there a Jira ticket? What is the delivery timeline relative to hub-core Phase 1?


Decision 10 — Transaction wrapping (Resolved with edge case)

Status: Resolved — one unmitigated edge case.

What was decided: ActiveRecord::Base.transaction wraps ContactList + Broadcast creation. CreateFromSegment accepts skip_enqueue: true. Worker enqueued after commit. Code sketch provided and implementable.

Unmitigated edge case: If Redis is briefly unavailable after the transaction commits but before perform_async succeeds, the ContactList row exists in processing but the worker is never enqueued. No Broadcast references it (the Broadcast was created inside the transaction and committed successfully). Result: a ContactList stuck in processing forever, campaign waiting, no alert.

The RFC acknowledges this: "mitigated by Sidekiq in-process reliability." In practice this is rare — but "rare" is not a spec.

Suggested resolution: Add to §3 Monitoring: "Alert if contact_lists.progress = 'processing' AND finished_at IS NULL AND created_at < 2.hours.ago — indicates a ContactList with no active worker. PagerDuty." Alternatively, add a scheduled cleanup job that re-enqueues orphaned ContactLists by checking message_broadcasts for a matching contact_list_id.


Data Integrity Deep-Dive

Write PathTransaction ScopePartial Failure BehaviorIdempotency KeyConsistency GuaranteeDuplicate Handling
ContactList creation (CreateFromSegment)Single DB write; worker enqueue after separate transactionContactList INSERT fails → Failure returned; no worker enqueuedNone (new record each time)Strongn/a — new record
ContactList + Broadcast creation (UserCreateBroadcast segment path)ActiveRecord::Base.transaction wraps both writes; worker enqueued after commit (Decision 10)Broadcast CREATE fails → Halt exception rolls back transaction; no orphan ContactListNoneStrong within transactionn/a — new records
CreateFromSegmentProcess per-page bulk importPer-page activerecord-import; each page rescue isolated (Decision 9)Per-page error: logged, skipped. 0 total → raise (Sidekiq retry). 3 retries exhausted → progress='failure'(contact_list_id, phone_number) + (contact_list_id, account_uniq_id) partial unique indexesEventualon_duplicate_key_update backed by unique indexes
ContactList.progress updateSingle UPDATEUPDATE fails → ES stale; Sidekiq retry re-processesn/aStrongn/a

Note on yield-inside-transaction: Decision 10's code sketch uses Dry::Monads yield inside ActiveRecord::Base.transaction. When yield receives a Failure, it raises Dry::Monads::Do::Halt (a StandardError subclass), which propagates through the transaction block and triggers a rollback. The code sketch is correct — but this non-obvious behavior is not documented. An agent implementing this should verify the interaction before shipping.


Concurrency Collision Map

#Shared ResourceWritersCollision ScenarioResolution MechanismLock Failure BehaviorAssessment
1contact_lists.progressCreateFromSegmentProcess workerTwo workers for same contact_list_id (unlikely — one enqueue per ContactList)Single enqueue per ContactList creation; unique-job plugin noted as optional enhancementSecond write wins — acceptableAdequate for Phase 1
2contact_list_recipients bulk importCreateFromSegmentProcess (Sidekiq retry)Retry re-imports same CDP page → duplicate rowson_duplicate_key_update backed by partial unique indexesIdempotent — no errorAdequate
3ContactList + Broadcast creationUserCreateBroadcast concurrent API requestsConcurrent campaigns for same org / same segmentEach creates a new ContactList — no shared resource; no collisionn/aNo concern

API Contract Completeness Check

EndpointRequest SchemaResponse SchemaError TaxonomyAuth SpecIdempotencyExample PayloadsAssessment
POST /broadcasts (segment path)Complete — new optional params + validation rulesComplete — 200 body example addedComplete — 4 conditions in §3.BSpecific — IAG JWT + customers_segment_viewMissing — not definedYes5/6
GET /contact_lists/:id/recipientsComplete — page/per_page with defaults and maxComplete — data array + pagination objectComplete — 404 + 401Specific — IAG JWT + org ownershipn/a — read-onlyYes6/6

Async Job / Event Consumer Spec

Job/ConsumerTriggerInput ShapeRetry PolicyDLQConcurrency LimitIdempotency KeyTimeoutAssessment
CreateRecipientFromSegmentWorkerperform_async after transaction commit{ contact_list_id:, segment_id:, organization_id:, segment_name: } JSON3 attempts, Sidekiq default backoffSidekiq dead queueNone (Sidekiq default)None per-job (ContactList unique per campaign)30s per CDP page6/7 — concurrency limit unspecified

Compliance Trigger Check

TriggerFound?Data LocationClassificationAssessment
PII — name, phoneYescontact_list_recipients.full_name, .phone_numberPII per UU PDPTransit: HTTPS ✓; at-rest: Open Q #5
PII — custom fieldsYescontact_extras.extra (JSONB)May include email, DOB, etc.Transit: HTTPS ✓; at-rest: Open Q #5 — unresolved
Payment dataNon/a
Health dataNon/a
Auth/session dataNon/a
Cross-border transferNot analyzedcontact-service.qontak.net data originUU PDP scopeGap: jurisdiction not analyzed

CDG Status: Active — scored at 7.0. Infosec approver required before 100% rollout.


Strengths

  • Exceptional pattern alignment (CPA: 9.5): §2.0 Source Verification table cites 10 real file anchors with specific line numbers and quoted identifiers (e.g., L14–18: enum progress: { processing: ..., success: ..., failure: ... }). Agent reads actual code before writing any new code — hallucination risk is near zero.
  • Decision 10 transaction boundary (DIC: 8.5): The skip_enqueue: true + post-commit perform_async pattern eliminates the orphan ContactList race condition. The code sketch is directly implementable. This is the hardest atomicity problem in the feature and the RFC solves it explicitly.
  • PRD traceability is bidirectional and complete (PRT: 9.0): All 11 PRD items resolved (7 implemented, 3 FE-scoped/n/a, 1 deferred). Per-story change map §1.C provides story → layer → BE changes → acceptance criteria → RFC anchors in one table. An agent knows exactly which code satisfies which PRD requirement.

Biggest Gaps

  • FMC — Faraday retry backoff unspecified (Decision 3, §2.C): "3 retries via Faraday retry middleware" with no interval means the agent generates interval: 0 — CDP receives 3 rapid-fire requests per page failure. At 200 pages × 3 Sidekiq retries, a CDP outage generates ~1,800 rapid CDP requests in seconds rather than a progressive backoff. Specify interval: 0.5, backoff_factor: 2, retry_statuses: [429, 500, 502, 503, 504] before Chunk 3.
  • SAS — organization_id source unspecified (§2.4 API contract extension): The POST /broadcasts contract extension does not state whether organization_id is extracted from JWT claims or taken from the request body. If from request body without JWT cross-check, a valid user can supply another tenant's org_id and create campaigns under that tenant's account. One sentence closes this: "organization_id extracted from JWT claims by [MiddlewareName]; request body value must match JWT-extracted value or return 403."
  • CDG — PII in plaintext JSONB (Open Q #5, §3.C): contact_extras.extra stores CDP custom fields (email, DOB, custom properties) as plaintext JSONB. RFC marks this "non-blocking for development" and defers to Infosec. The agent generates unencrypted PII storage. If Infosec requires LOCKBOX, the retrofit touches bulk import, query path, and ES indexing — estimated 2+ days of rework. Resolve the encryption decision before Chunk 4 is implemented, not after.

Priority Actions

  1. Decision 3 / §2.C — Add Faraday retry backoff values before Chunk 3: Specify interval: 0.5, backoff_factor: 2, max_interval: 10, retry_statuses: [429, 500, 502, 503, 504] in the SegmentClient spec. 5-minute fix that prevents CDP hammering on failure and gives the agent a complete Faraday configuration.

  2. §2.4 API contract — Specify organization_id source before Chunk 6: Add one sentence to the POST /broadcasts contract extension: "organization_id is extracted from JWT claims by [IAGMiddleware class name]; the request body organization_id must match the JWT-extracted value." This closes the tenant-hopping gap and tells the agent exactly where to get the value.

  3. CDG / §3.C — Resolve encryption before Chunk 4: (a) Check whether existing contact_extras.extra already uses LOCKBOX. (b) If not, either specify LOCKBOX encryption for the bulk import path (with column alias migration) in §2.3 DDL + §3.C, or get explicit Infosec sign-off that plaintext JSONB is acceptable. Agent should not implement unencrypted PII storage if the answer is "we'll figure this out later."

  4. Decision 8 / §5 Known Limitation 9 — Create wa_cloud ticket before Phase 1 production launch: Assign a Jira ticket to wa_cloud team for the BroadcastSpecificWorker progress check update. Add ticket reference + owner to §5 Known Limitation 9. Move from "non-blocking" to "required before 100% rollout" in §7. Without this, partially_completed campaigns silently skip at execution — no error, 0 messages sent, user has no signal.

  5. Decision 10 code sketch — Document Dry::Monads::Do::Halt behavior: Add a one-line comment: "yield inside ActiveRecord::Base.transaction propagates Dry::Monads::Do::Halt (a StandardError subclass) which triggers automatic rollback." An agent or engineer unfamiliar with this interaction may add a begin/rescue wrapper that catches the Halt before the transaction can roll back, silently breaking the atomicity guarantee.


Backend Contract Addendum

Endpoint Contract Details

EndpointMethod/PathAuthZRequest ContractResponse ContractError ContractIdempotency/VersioningStatus
Extend broadcast createPOST /broadcastsIAG JWT + customers_segment_view; organization_id scopedsegment_id (optional, string), segment_name (optional, string), estimated_recipient_count (optional, int, >0); mutual-exclusivity rule with contact_list_id200: { data: { id, name, status, send_at, contact_list_id, execute_type, created_at } }422: INVALID_SCHEDULE, INSUFFICIENT_BALANCE, RECURRING_NOT_ALLOWED, contact_list_id missingIdempotency: not defined (same as existing)Complete — idempotency gap noted
Recipient listGET /contact_lists/:id/recipientsIAG JWT + org ownershippage (int, default 1), per_page (int, default 10, max 200)200: { data: [{ full_name, phone_number, account_uniq_id }], pagination: { page, per_page, total, total_pages } }404: NOT_FOUND; 401: unauthenticatedRead-only — no idempotency concernComplete

Database Changes Details

ChangeTable/EntityDDL / Shape DiffData Migration PlanRollback PlanCompatibility WindowStatus
Add segment_idcontact_listsVARCHAR NULL + partial index WHERE segment_id IS NOT NULLNo backfill (nullable; only new rows)rake db:rollback drops columnExisting rows unaffected (NULL)Complete
Add idempotency indexescontact_list_recipientsUNIQUE (contact_list_id, phone_number) WHERE phone_number IS NOT NULL + UNIQUE (contact_list_id, account_uniq_id) WHERE account_uniq_id IS NOT NULL AND phone_number IS NULLNo backfill; indexes only constrain new insertsDROP INDEX both indexesNew behavior on segment-based inserts onlyComplete
Add partially_completed enum valuecontact_lists.progressString column — no ALTER TYPE; add enum value in Models::ContactListNo migration neededRemove enum value + scan for existing rows (0 at rollback time)n/aComplete

Implementation Readiness Checklist

Unblocked (agent can proceed)

  • PRD → RFC traceability matrix complete (11/11 PRD items)
  • All technical decisions resolved (9/10 fully, Decision 8 partial — non-blocking)
  • All failure modes handled per external interaction with error message catalog (§3.A, §3.B)
  • Configuration contract: 5 env vars/flags in §4.A
  • Rollout plan with feature flag and rollback mechanism (§4)
  • Observability metrics and alerts defined (§3 Monitoring)
  • Task decomposition with acceptance criteria per chunk (§4.C, 8 chunks)
  • Schema changes at DDL-level precision (§2.3 — delta DDL for additions)
  • API contracts with request/response schemas, error taxonomy, examples (§2.4)
  • Transaction boundaries and idempotency keys per write path (§2.A, Decision 10)
  • Concurrency collision points with resolution mechanisms (§2.B)
  • Security: auth boundaries, input validation, tenancy isolation (§3)
  • Migration plan: zero-downtime, nullable column, rollback script (§4.D)
  • Service boundary and coupling documented (§2.D, SBC)
  • Compliance handled with gating (§3.C — Open Q #5 gated by Infosec)

Required before 100% rollout (do not block Chunks 1–8)

  • Open Q #5: at-rest encryption for contact_extras.extraInfosec gate
  • Decision 8: BroadcastSpecificWorker update — wa_cloud ticket required
  • Open Q #2: CDP p99 latency measurement in staging — go/no-go gate

Verdict: Ready to implement Chunks 1–8. Fix 2 items before 100% rollout.


Task Manifest

Verified from RFC §4.C. Execution order 1 → 3 → 2 → 4 → 5 → 6 → 7 → 8 refers to chunk numbers, not ordinal positions.

OrderChunk #ChunkFiles to Create/ModifyAcceptance CriteriaDependencies
11DB migration: segment_id + idempotency indexesdatabase/core/db/migrate/YYYYMMDDHHMMSS_add_segment_id_to_contact_lists.rbNullable segment_id; 2 partial unique indexes; rollback succeedsNone
23CDP service clientapp/apps/broadcast_service/services/cdp/segment_client.rbfetch_customers responds; Faraday timeouts; BasicAuth from ENVCDP credentials in staging
37Feature flag registrationrake task or migration commentsend_campaign_with_segment exists in DB/FlipperChunk 1
44CreateFromSegment repository + specapp/core/domains/repositories/contact_lists/create_from_segment.rb + specContactList: source_type='segment', progress='processing', segment_id; worker enqueued; wrong org → FailureChunk 1
52CreateRecipientFromSegmentWorker + CreateFromSegmentProcess + specs2 new files + 2 specsRecipients created; progress='success'; cap at 20K; 0-customer → failure; BSUID fallback; partially_completed on partial page failureChunks 1, 3
65Extend UserCreateBroadcastapp/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rbTransaction wrapping; skip_enqueue: true; 1-hour validation; balance validation; recurring block; create_campaign_audience_type metric; existing path unaffectedChunks 1, 4
76GET /contact_lists/:id/recipients repository + interactorapp/core/domains/repositories/contact_list_recipients/list.rb + specSorted full_name ASC; pagination; wrong org → 404; route in hub-service (separate)Chunk 1
88Full suite + lint + securityNo new filesRubocop 0 offenses; rspec green; Brakeman no new HIGHAll chunks

Dangling Decisions Log

#DecisionLocationOwnerDeadline
1BroadcastSpecificWorker must check progress IN ('success', 'partially_completed') — prerequisite for Decision 9 end-to-end effect§5 Known Limitation 9, §7 non-blockingwa_cloud team (no ticket yet)Before Phase 1 100% rollout

Open Questions

#QuestionCategorySeverity
1What is CDP's rate limit on GET /api/v1/segments/:id/customers? Needed to set Faraday retry interval safely.FMCBlocking — needed before Chunk 3
2Is organization_id taken from JWT claims or request body in POST /broadcasts? What enforces the org match?SASBlocking — needed before Chunk 6
3Does contact_extras.extra in existing flows already use LOCKBOX encryption? If yes: segment path is covered. If no: migration needed or Infosec sign-off needed.CDGImportant — needed before Chunk 4 goes to production
4Has wa_cloud team acknowledged the BroadcastSpecificWorker update? Is there a Jira ticket with a delivery date?DIC / Decision 8Important — needed before 100% rollout
5What is the minimum total_created threshold before partially_completed allows campaign execution? (e.g., must be ≥10% of estimated count?)TDC / Decision 9Important
6CDP p99 latency for GET /api/v1/segments/:id/customers in staging? Worker SLO (99% within 30min) depends on this.OBS / CSSImportant — needed before go/no-go to 5%
7Where is CDP contact-service.qontak.net hosted? If outside Indonesia, UU PDP cross-border transfer analysis applies.CDGNice-to-have
8Is per_page > 200 on GET /contact_lists/:id/recipients a silent cap (returns 200 rows) or a 422?ACVNice-to-have

Score Summary

CategoryScoreRating
PRT — PRD Traceability9.0Strong
TDC — Technical Decisions9.0Strong
DMS — Data Model & Schema8.0Strong
ACV — API Contract & Versioning8.0Strong
DIC — Data Integrity & Consistency8.5Strong
FMC — Failure Mode & Retry Coverage8.0Strong
CSS — Concurrency & Scaling7.0Adequate
SAS — Security & Authorization7.5Adequate
MRP — Migration & Rollout Plan8.5Strong
OBS — Observability Definition8.0Strong
SBC — Service Boundary & Coupling8.5Strong
CPA — Pattern Alignment9.5Exceptional
CDG — Compliance & Data Governance6.5Needs attention
Overall8.0Strong — PROCEED with notes

Score caps checked: No cap triggered. CDG 6.5 > 5.0 threshold. FMC 8.0 > 4.0 threshold. All others above respective minimums.

Path to Agentic-Ready (8.5+): Resolve Faraday backoff (FMC → 8.5), specify organization_id source (SAS → 8.0), document Sidekiq concurrency limit (CSS → 7.5), resolve CDG encryption (CDG → 7.5). That would bring all categories above 7.5 and make the RFC eligible for 8.5 overall.


Evidence Notes

  • §2.0 Source Verification — 10-row anti-hallucination table with specific line numbers, quoted identifiers, and function names. Every design choice anchored in verified real code. This is the strongest pattern alignment section seen in a new-feature RFC of this complexity.
  • Decision 10 code sketchyield inside ActiveRecord::Base.transaction is correct (Dry::Monads::Do::Halt propagates through the block and triggers rollback). The interaction is non-obvious but valid. The sketch is implementable without modification.
  • §2.3 DDL — partial unique indexesidx_clr_idempotency_bsuid WHERE clause is precisely correct: account_uniq_id IS NOT NULL AND phone_number IS NULL. Prevents double-indexing when phone is present (phone takes precedence as idempotency key).
  • §5 open questions — 4 of 6 closed (Q#1 pagination, Q#3 orphan, Q#4 idempotency, Q#6 CDP fields). Q#2 (p99) and Q#5 (PII encryption) remain. Both are correctly classified as non-blocking for implementation start; Q#5 must be resolved before 100% rollout.
  • §2.C orchestrator sketchCreateFromSegmentProcess code sketch shows the complete per-page rescue loop with failed_pages tracking, total_created counter, and partially_completed determination logic. Directly implementable. One gap: no rescue block around contact_list.__elasticsearch__.index_document — agent generates a bare ES call.
  • §2.A Data Integrity Matrix — All 4 write paths covered with transaction scope, partial failure, idempotency, consistency model, and duplicate handling. The only unaddressed scenario is the Redis-blip-after-commit edge case (ContactList in processing forever). Rare but observable at scale.
  • §2.4 POST /broadcasts contractcontact_list_id mutual-exclusivity rule is correctly specified as a dry-validation rule(:contact_list_id, :segment_id) block. Backward compat statement is explicit. Error taxonomy is complete with conditions, HTTP status, messages, and user-facing flag.