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-commitperform_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_idsource (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 changed | Impact |
|---|---|
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 criteria | Unchanged — already strong |
| PRD Element | RFC Section | Coverage |
|---|---|---|
| Story 1–8 (FE UX) | §1.A — n/a FE RFC | Full — explicitly out of scope |
| Story 9 — Create Campaign with Segment | §2.4, §4.C Chunk 5, §1.C row 9 | Full |
| Story 10 — Async generate recipients | §2.C, §2.3 DDL, §4.C Chunks 3–4 | Full |
| Story 11 — 1-hour schedule window | §2.4 contract rule, §4.C Chunk 5 | Full |
| Story 12 — Campaign Detail customer list | §2.4 GET endpoint, §4.C Chunk 6 | Full |
| 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 mapping | Full |
| Story 15 — Measure campaign usage | §1.C, §2.4 create_campaign_audience_type | Full — gap closed in this revision |
| Phase 2 batching | §1 Out of Scope | Full — explicitly deferred |
| CDP field mapping | §2.C field mapping table | Full |
| RBAC segment view | §3 SAS, §3 Role × Endpoint matrix | Full |
Summary: 11 of 11 PRD items fully covered. 0 partial. 0 missing. 0 RFC decisions without PRD justification.
Scorecard
Backend Scorecard
| Category | Score | Evidence-Based Rationale |
|---|---|---|
| PRT — PRD Traceability | 9.0 | Bidirectional 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 Decisions | 9.0 | 10 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 & Schema | 8.0 | DDL 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 & Versioning | 8.0 | POST /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 & Consistency | 8.5 | Full 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 Coverage | 8.0 | CDP 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 & Scaling | 7.0 | Concurrency 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 & Authorization | 7.5 | Threat 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 Plan | 8.5 | Feature 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 Definition | 8.0 | Metric: 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 & Coupling | 8.5 | hub-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 Alignment | 9.5 | Exceptional. 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 Governance | 6.5 | CDG 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
| # | Decision | Status | Critical Gaps |
|---|---|---|---|
| 1 | segment_id column on contact_lists | Resolved | None |
| 2 | Async Sidekiq worker | Resolved | None |
| 3 | CDP integration via Services::Cdp::SegmentClient | Resolved | Faraday retry backoff interval unspecified |
| 4 | 1-hour schedule enforcement in UserCreateBroadcast contract | Resolved | None |
| 5 | Balance validation with FE-provided estimated count + 10% buffer | Resolved | None |
| 6 | New CreateFromSegment repository (clone CreateDirectSelectAll) | Resolved | None |
| 7 | Offset-based CDP pagination (page/per_page max 100) | Resolved | None |
| 8 | Consistency model — eventual; success OR partially_completed | Partial | No standalone ADR block; wa_cloud BroadcastSpecificWorker update has no ticket or owner |
| 9 | partially_completed status with per-page error handling | Resolved | Minimum success threshold for partially_completed → execute not specified |
| 10 | Transaction wrapping ContactList + Broadcast; worker enqueued after commit | Resolved | Redis-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 Path | Transaction Scope | Partial Failure Behavior | Idempotency Key | Consistency Guarantee | Duplicate Handling |
|---|---|---|---|---|---|
ContactList creation (CreateFromSegment) | Single DB write; worker enqueue after separate transaction | ContactList INSERT fails → Failure returned; no worker enqueued | None (new record each time) | Strong | n/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 ContactList | None | Strong within transaction | n/a — new records |
CreateFromSegmentProcess per-page bulk import | Per-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 indexes | Eventual | on_duplicate_key_update backed by unique indexes |
ContactList.progress update | Single UPDATE | UPDATE fails → ES stale; Sidekiq retry re-processes | n/a | Strong | n/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 Resource | Writers | Collision Scenario | Resolution Mechanism | Lock Failure Behavior | Assessment |
|---|---|---|---|---|---|---|
| 1 | contact_lists.progress | CreateFromSegmentProcess worker | Two workers for same contact_list_id (unlikely — one enqueue per ContactList) | Single enqueue per ContactList creation; unique-job plugin noted as optional enhancement | Second write wins — acceptable | Adequate for Phase 1 |
| 2 | contact_list_recipients bulk import | CreateFromSegmentProcess (Sidekiq retry) | Retry re-imports same CDP page → duplicate rows | on_duplicate_key_update backed by partial unique indexes | Idempotent — no error | Adequate |
| 3 | ContactList + Broadcast creation | UserCreateBroadcast concurrent API requests | Concurrent campaigns for same org / same segment | Each creates a new ContactList — no shared resource; no collision | n/a | No concern |
API Contract Completeness Check
| Endpoint | Request Schema | Response Schema | Error Taxonomy | Auth Spec | Idempotency | Example Payloads | Assessment |
|---|---|---|---|---|---|---|---|
POST /broadcasts (segment path) | Complete — new optional params + validation rules | Complete — 200 body example added | Complete — 4 conditions in §3.B | Specific — IAG JWT + customers_segment_view | Missing — not defined | Yes | 5/6 |
GET /contact_lists/:id/recipients | Complete — page/per_page with defaults and max | Complete — data array + pagination object | Complete — 404 + 401 | Specific — IAG JWT + org ownership | n/a — read-only | Yes | 6/6 |
Async Job / Event Consumer Spec
| Job/Consumer | Trigger | Input Shape | Retry Policy | DLQ | Concurrency Limit | Idempotency Key | Timeout | Assessment |
|---|---|---|---|---|---|---|---|---|
CreateRecipientFromSegmentWorker | perform_async after transaction commit | { contact_list_id:, segment_id:, organization_id:, segment_name: } JSON | 3 attempts, Sidekiq default backoff | Sidekiq dead queue | None (Sidekiq default) | None per-job (ContactList unique per campaign) | 30s per CDP page | 6/7 — concurrency limit unspecified |
Compliance Trigger Check
| Trigger | Found? | Data Location | Classification | Assessment |
|---|---|---|---|---|
| PII — name, phone | Yes | contact_list_recipients.full_name, .phone_number | PII per UU PDP | Transit: HTTPS ✓; at-rest: Open Q #5 |
| PII — custom fields | Yes | contact_extras.extra (JSONB) | May include email, DOB, etc. | Transit: HTTPS ✓; at-rest: Open Q #5 — unresolved |
| Payment data | No | — | — | n/a |
| Health data | No | — | — | n/a |
| Auth/session data | No | — | — | n/a |
| Cross-border transfer | Not analyzed | contact-service.qontak.net data origin | UU PDP scope | Gap: 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-commitperform_asyncpattern 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. Specifyinterval: 0.5, backoff_factor: 2, retry_statuses: [429, 500, 502, 503, 504]before Chunk 3. - SAS —
organization_idsource unspecified (§2.4 API contract extension): The POST /broadcasts contract extension does not state whetherorganization_idis 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.extrastores 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
-
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 theSegmentClientspec. 5-minute fix that prevents CDP hammering on failure and gives the agent a complete Faraday configuration. -
§2.4 API contract — Specify
organization_idsource 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 bodyorganization_idmust match the JWT-extracted value." This closes the tenant-hopping gap and tells the agent exactly where to get the value. -
CDG / §3.C — Resolve encryption before Chunk 4: (a) Check whether existing
contact_extras.extraalready 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." -
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
BroadcastSpecificWorkerprogress 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_completedcampaigns silently skip at execution — no error, 0 messages sent, user has no signal. -
Decision 10 code sketch — Document
Dry::Monads::Do::Haltbehavior: Add a one-line comment: "yieldinsideActiveRecord::Base.transactionpropagatesDry::Monads::Do::Halt(a StandardError subclass) which triggers automatic rollback." An agent or engineer unfamiliar with this interaction may add abegin/rescuewrapper that catches the Halt before the transaction can roll back, silently breaking the atomicity guarantee.
Backend Contract Addendum
Endpoint Contract Details
| Endpoint | Method/Path | AuthZ | Request Contract | Response Contract | Error Contract | Idempotency/Versioning | Status |
|---|---|---|---|---|---|---|---|
| Extend broadcast create | POST /broadcasts | IAG JWT + customers_segment_view; organization_id scoped | segment_id (optional, string), segment_name (optional, string), estimated_recipient_count (optional, int, >0); mutual-exclusivity rule with contact_list_id | 200: { data: { id, name, status, send_at, contact_list_id, execute_type, created_at } } | 422: INVALID_SCHEDULE, INSUFFICIENT_BALANCE, RECURRING_NOT_ALLOWED, contact_list_id missing | Idempotency: not defined (same as existing) | Complete — idempotency gap noted |
| Recipient list | GET /contact_lists/:id/recipients | IAG JWT + org ownership | page (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: unauthenticated | Read-only — no idempotency concern | Complete |
Database Changes Details
| Change | Table/Entity | DDL / Shape Diff | Data Migration Plan | Rollback Plan | Compatibility Window | Status |
|---|---|---|---|---|---|---|
Add segment_id | contact_lists | VARCHAR NULL + partial index WHERE segment_id IS NOT NULL | No backfill (nullable; only new rows) | rake db:rollback drops column | Existing rows unaffected (NULL) | Complete |
| Add idempotency indexes | contact_list_recipients | UNIQUE (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 NULL | No backfill; indexes only constrain new inserts | DROP INDEX both indexes | New behavior on segment-based inserts only | Complete |
Add partially_completed enum value | contact_lists.progress | String column — no ALTER TYPE; add enum value in Models::ContactList | No migration needed | Remove enum value + scan for existing rows (0 at rollback time) | n/a | Complete |
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.extra— Infosec gate - Decision 8:
BroadcastSpecificWorkerupdate — 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.
| Order | Chunk # | Chunk | Files to Create/Modify | Acceptance Criteria | Dependencies |
|---|---|---|---|---|---|
| 1 | 1 | DB migration: segment_id + idempotency indexes | database/core/db/migrate/YYYYMMDDHHMMSS_add_segment_id_to_contact_lists.rb | Nullable segment_id; 2 partial unique indexes; rollback succeeds | None |
| 2 | 3 | CDP service client | app/apps/broadcast_service/services/cdp/segment_client.rb | fetch_customers responds; Faraday timeouts; BasicAuth from ENV | CDP credentials in staging |
| 3 | 7 | Feature flag registration | rake task or migration comment | send_campaign_with_segment exists in DB/Flipper | Chunk 1 |
| 4 | 4 | CreateFromSegment repository + spec | app/core/domains/repositories/contact_lists/create_from_segment.rb + spec | ContactList: source_type='segment', progress='processing', segment_id; worker enqueued; wrong org → Failure | Chunk 1 |
| 5 | 2 | CreateRecipientFromSegmentWorker + CreateFromSegmentProcess + specs | 2 new files + 2 specs | Recipients created; progress='success'; cap at 20K; 0-customer → failure; BSUID fallback; partially_completed on partial page failure | Chunks 1, 3 |
| 6 | 5 | Extend UserCreateBroadcast | app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb | Transaction wrapping; skip_enqueue: true; 1-hour validation; balance validation; recurring block; create_campaign_audience_type metric; existing path unaffected | Chunks 1, 4 |
| 7 | 6 | GET /contact_lists/:id/recipients repository + interactor | app/core/domains/repositories/contact_list_recipients/list.rb + spec | Sorted full_name ASC; pagination; wrong org → 404; route in hub-service (separate) | Chunk 1 |
| 8 | 8 | Full suite + lint + security | No new files | Rubocop 0 offenses; rspec green; Brakeman no new HIGH | All chunks |
Dangling Decisions Log
| # | Decision | Location | Owner | Deadline |
|---|---|---|---|---|
| 1 | BroadcastSpecificWorker must check progress IN ('success', 'partially_completed') — prerequisite for Decision 9 end-to-end effect | §5 Known Limitation 9, §7 non-blocking | wa_cloud team (no ticket yet) | Before Phase 1 100% rollout |
Open Questions
| # | Question | Category | Severity |
|---|---|---|---|
| 1 | What is CDP's rate limit on GET /api/v1/segments/:id/customers? Needed to set Faraday retry interval safely. | FMC | Blocking — needed before Chunk 3 |
| 2 | Is organization_id taken from JWT claims or request body in POST /broadcasts? What enforces the org match? | SAS | Blocking — needed before Chunk 6 |
| 3 | Does 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. | CDG | Important — needed before Chunk 4 goes to production |
| 4 | Has wa_cloud team acknowledged the BroadcastSpecificWorker update? Is there a Jira ticket with a delivery date? | DIC / Decision 8 | Important — needed before 100% rollout |
| 5 | What is the minimum total_created threshold before partially_completed allows campaign execution? (e.g., must be ≥10% of estimated count?) | TDC / Decision 9 | Important |
| 6 | CDP p99 latency for GET /api/v1/segments/:id/customers in staging? Worker SLO (99% within 30min) depends on this. | OBS / CSS | Important — needed before go/no-go to 5% |
| 7 | Where is CDP contact-service.qontak.net hosted? If outside Indonesia, UU PDP cross-border transfer analysis applies. | CDG | Nice-to-have |
| 8 | Is per_page > 200 on GET /contact_lists/:id/recipients a silent cap (returns 200 rows) or a 422? | ACV | Nice-to-have |
Score Summary
| Category | Score | Rating |
|---|---|---|
| PRT — PRD Traceability | 9.0 | Strong |
| TDC — Technical Decisions | 9.0 | Strong |
| DMS — Data Model & Schema | 8.0 | Strong |
| ACV — API Contract & Versioning | 8.0 | Strong |
| DIC — Data Integrity & Consistency | 8.5 | Strong |
| FMC — Failure Mode & Retry Coverage | 8.0 | Strong |
| CSS — Concurrency & Scaling | 7.0 | Adequate |
| SAS — Security & Authorization | 7.5 | Adequate |
| MRP — Migration & Rollout Plan | 8.5 | Strong |
| OBS — Observability Definition | 8.0 | Strong |
| SBC — Service Boundary & Coupling | 8.5 | Strong |
| CPA — Pattern Alignment | 9.5 | Exceptional |
| CDG — Compliance & Data Governance | 6.5 | Needs attention |
| Overall | 8.0 | Strong — 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 sketch —
yieldinsideActiveRecord::Base.transactionis 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 indexes —
idx_clr_idempotency_bsuidWHERE 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 sketch —
CreateFromSegmentProcesscode sketch shows the complete per-page rescue loop withfailed_pagestracking,total_createdcounter, andpartially_completeddetermination logic. Directly implementable. One gap: no rescue block aroundcontact_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
processingforever). Rare but observable at scale. - §2.4 POST /broadcasts contract —
contact_list_idmutual-exclusivity rule is correctly specified as a dry-validationrule(:contact_list_id, :segment_id)block. Backward compat statement is explicit. Error taxonomy is complete with conditions, HTTP status, messages, and user-facing flag.