Skip to main content

RFC: Multi-BSUID Support — CDP × CRM Phone Sync

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory.

It is also agent-execution-ready: §1 Schema Derivation (BE), §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan + Verification & Rollback Recipe are complete.

Grounding note (anti-hallucination): file paths, struct names, method names, and endpoint paths in this RFC are sourced from the Confluence DRAFT RFC (page 51208847634, version 4, fetched 2026-06-22). They are not yet verified against the live worktrees — the Source Verification table in §2.0 marks each anchor with its provenance. Every path tagged [confluence — verify] must be confirmed by reading the actual file before the agent executes that chunk.

Metadata

FieldValueNotes
StatusRFC (DRAFT)Human label; YAML status: carries linter enum draft
DRITBDAssign CDP Engineering Tech Lead before first review
TeamcdpAdvisory squad slug
Author(s)CDP Engineering Team
ReviewersCDP Tech Lead, CRM Tech Lead, Engineering ManagerCross-squad sign-off required
Approver(s)CDP Tech Lead, CRM Tech Lead
Submitted Date2026-06-08ISO-8601
Last Updated2026-06-22ISO-8601
Target Release2026-Q2Quarter
Target Quarter2026-Q2Advisory
Deliverynot yet handed to deliveryUpdate once delivery/timeline.md exists
RelatedConfluence RFC page 51208847634Source DRAFT RFC; no separate PRD exists
Discussion#cdp-ops (Slack)

Type: backend Sub-type: enhancement (existing phone sync path; payload shape change)

Sections at a Glance

  1. Overview (problem, goals, constraints, schema derivation, traceability)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Sequence Diagrams → DDL → APIs → async job spec)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (cross-service matrix, Agent Execution Plan, Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

A customer in CDP (contact-service, Go) can have multiple phone numbers. Each phone number that originates from a WhatsApp channel carries a bsuid — the unique business-subscriber identifier for that channel subscription. Today, when CDP syncs a contact to CRM (qontak.com, Rails), it sends the phone field as a flat array of strings:

"phone": ["081234", "089999"]

CRM has no way to know which BSUID belongs to which phone number. As a result, CRM cannot link the correct crm_phones row to the correct BSUID, breaking the multi-BSUID model at the CRM storage layer.

The fix: change the CDP → CRM payload so each phone entry is an object carrying both the phone number and its BSUID:

"phone": [
{ "phone_number": "081234", "bsuid": "abc123" },
{ "phone_number": "089999", "bsuid": null }
]

This change is scoped exclusively to the CDP × CRM sync path. The Chat sync path (ContactStaticDataChanges, ContactCreateDeleteDataSent, QontakChatClient.UpdateContact, UpdateContactChat, CreateContactChat) is explicitly untouched.

Success Criteria

  1. CRM receives [{phone_number, bsuid}] for flag-enabled companies; crm_phones.bsuid is populated correctly on create and update.
  2. Immutability guard respected: zero guard-aborted saves in CRM logs on re-sync of already-linked phones.
  3. Instant rollback verified: disabling crm_phone_object flag reverts CDP to legacy flat-string payload with no CRM-side change needed.
  4. Chat flows unchanged: zero new errors on Chat create/update paths after deployment.
  5. Diff coverage ≥ 95% on all new Go code paths (TF-3155).
  6. CRM sync success rate > 99.5% for create and update CRM jobs post-flag-enablement.

Out of Scope

  1. Chat sync payloadsContactStaticDataChanges, ContactCreateDeleteDataSent, QontakChatClient.UpdateContact, UpdateContactChat, CreateContactChat consumers are not touched.
  2. CRM delete flow — still uses flat []string phone format; no change.
  3. CDP ContactHandler / ContactApiHandler Kafka paths (contact_create, contact_update) — out of scope.
  4. Backfill of existing crm_phones rows with blank BSUID — post-GA follow-up job, not this RFC.
  5. Direct CRM phone edits — phones added directly in CRM (not via WhatsApp Chat) will always have bsuid = null by design; no ChatData entry exists.
  • Source Confluence DRAFT RFC: https://jurnal.atlassian.net/wiki/spaces/QON/pages/51208847634
  • Jira TF-3154: CRM schema prerequisite (bsuid column + immutability guard on crm_phones)
  • Jira TF-3155: CDP producer — new payload structs + feature-flag-gated sync
  • Jira TF-3156: CRM consumer — backward-compatible normalize_phones

Assumptions

  • ChatData is always in memory: the contact.Contact struct loaded during sync already contains ChatData *[]ChatData with PhoneNumber and Bsuid fields — no additional DB query is needed to resolve BSUIDs. (Verify: confirm Contact struct loads ChatData eagerly on sync path.)
  • CRM deployed first: TF-3156 is safe to deploy before TF-3155 because CRM handles both string and object entries. CDP flag stays OFF until CRM is deployed.
  • TF-3154 is a hard prerequisite: the bsuid column on crm_phones and the immutability guard must exist before any BSUID values are written.
  • Queue backward compatibility: during flag rollout, the gocraft work queue may contain both old-shape (string) and new-shape (object) jobs; the isPhoneObjectArray dispatch in the consumers handles both without a queue flush.

Dependencies

DependencyOwnerLayerAvailabilityBlocking?
IFeatureFlagService.FeatureEnabled(ctx, flag, companySSOID)CDPcontact-service (Go)Exists — per-company, Redis-cached, MongoDB-backedReuse
gocraft work queue + RedisCDP Infracontact-service (Go)Exists — used for all CRM sync workersReuse
crm_phones table with bsuid column + immutability guardCRMqontak.com (Rails)TF-3154 prerequisite — not yet deployedYES — must merge before TF-3155/3156
Crm::CentralizedContacts::Update classCRMqontak.com (Rails)Exists — handles update_contact inboundExtend (TF-3156)
POST /crm/centralized_contacts/create_contactCRMqontak.com (Rails)ExistsReuse
POST /crm/centralized_contacts/update_contactCRMqontak.com (Rails)ExistsReuse

PRD-to-Schema Derivation

No separate PRD exists for this initiative. This section derives the schema/contract directly from the Confluence DRAFT RFC business rules.

Business rule / entityPersisted asExposed viaEnforced where
Each phone number carries its originating BSUID (WhatsApp subscription identifier)crm_phones.bsuid varchar (new column, TF-3154)CDP → CRM sync payload phone: [{phone_number, bsuid}]buildCrmContactPhones() in CDP; normalize_phones() in CRM Crm::CentralizedContacts::Update
BSUID sourced from contact.ChatData matching on phone numberread-only from Contact.ChatData[].PhoneNumber + .Bsuidn/a — serializationbuildCrmContactPhones()
Non-WhatsApp phones (no ChatData match) carry bsuid: nullcrm_phones.bsuid = nullpayload bsuid: nullbuildCrmContactPhones() — nil pointer when no match
Once a crm_phones.bsuid is set it must not change (immutability)DB-level before_update / before_destroy guard (TF-3154)CRM save aborted with guard errorActiveRecord callback in crm_phones model
New payload shape is gated per companyRedis-cached flag crm_phone_objectIFeatureFlagService.FeatureEnabled(ctx, "crm_phone_object", companySSOID)SyncActionService.Sync() + UpdateStaticDataCrm() branching
Legacy flat-string payload ([]string) must remain valid for un-flagged companies and during queue drainno storage changeCRM normalize_phones() detects String vs Hash per entryCRM consumer
CRM delete flow uses flat []string — unchangedno change to ContactCreateDeleteDataSentunchanged delete endpointn/a — out of scope

Detail 1.A — Traceability Matrix

No PRD story table exists; tracing against the Confluence RFC goals and Jira tickets.

Forward (requirement → RFC):

RequirementService / artifactRFC section
Each phone carries BSUID in CDP→CRM payloadContactPhone struct, buildCrmContactPhones, ContactStaticDataChangesCrm§2 Decision 1, §2.3, §2.4, §4.D chunk 2
Feature flag gates new payload shape per companyFeatureFlagCrmPhoneObject const, SetFeatureFlagService, Sync() branch§2 Decision 3, §4.D chunk 3
CRM handles both payload shapes safelynormalize_phones(), entry-type detection§2 Decision 2, §4.D chunk 6
crm_phones.bsuid immutability guardbefore_update/before_destroy ActiveRecord callbacks§2 Decision 4, §2.3 DDL, §4.D chunk 5
Chat sync paths unchangedout of scope; ContactStaticDataChanges not modified§1 Out of Scope, §2.I scope boundaries
Rollback by flag disable onlyIFeatureFlagService, no CRM deployment needed§4 Rollout Strategy, §2 Decision 3

Reverse (RFC artifact → requirement):

New artifactRequirement served
ContactPhone{PhoneNumber string; Bsuid *string} struct (Go)BSUID per phone in payload
ContactStaticDataChangesCrm struct (Go)CRM-specific update payload with []ContactPhone
ContactCreateDeleteDataSentCrm struct (Go)CRM-specific create/delete payload
buildCrmContactPhones(contact Contact) []ContactPhoneBSUID resolution from ChatData
transformCreateDeleteDataPayloadCrm(...) helperCRM-specific create/delete transform
SetFeatureFlagService(IFeatureFlagService) on SyncActionServiceDependency injection for flag service
CreateContactV2 / UpdateContactV2 on QontakCrmClientV2 API calls with object phone shape
isPhoneObjectArray(payload) bool dispatch helperBackward-compatible queue drain
normalize_phones(phones) in Crm::CentralizedContacts::Update (Ruby)CRM consumer reads new shape
bsuid column + immutability guard on crm_phones (Rails migration)Persistent BSUID linkage

Role / Consumer Coverage

ConsumerAuthorizationInbound sourceOutbound effectCross-tenant?
contact-service worker (gocraft job)internal service — no user authgocraft queue (job enqueued by SyncActionService)POST /crm/centralized_contacts/create_contact or /update_contactno — company-scoped by companySSOID on each request
Crm::CentralizedContacts::Update (Rails)CRM internal service API (auth via service token)POST /crm/centralized_contacts/update_contactwrites crm_phones.bsuidno — crm_phones scoped per contact

Detail 1.B — Key Decisions Summary

#DecisionChosen option§2 block
1Phone payload shape[]ContactPhone{PhoneNumber, Bsuid} object array for CRM pathDecision 1
2CRM backward compatibilityEntry-type detection (Hash vs String) in normalize_phonesDecision 2
3Feature flag mechanismPer-company IFeatureFlagService.FeatureEnabled("crm_phone_object", companySSOID)Decision 3
4BSUID immutabilitybefore_update/before_destroy ActiveRecord guard in crm_phones modelDecision 4
5BSUID sourcingChatData field already on loaded Contact struct — no extra DB queryDecision 5
6Deploy orderTF-3154 (CRM schema) → TF-3156 (CRM consumer) → TF-3155 (CDP) → flag enableDecision 6
7Chat path isolationParallel CRM-specific structs; existing ContactStaticDataChanges untouchedDecision 7

Detail 1.C — Per-Story Change Map

TicketTitleLayer scopeChangesAcceptance criteria
TF-3154CRM schema + immutability guardBE-only (Rails)Add bsuid varchar to crm_phones; before_update/before_destroy callbacks; migration + rollbackMigration applies cleanly; guard blocks BSUID overwrite; guard allows first-time write; unit tests pass
TF-3156CRM consumer backward-compatible updateBE-only (Rails)normalize_phones in Crm::CentralizedContacts::Update; handle both String and Hash entries; write bsuid from object shapeRSpec: String path (legacy) writes nil bsuid; Hash path writes bsuid; mixed payload; blank phone filtered; no regression on update_contact
TF-3155CDP producer — new payload + flagBE-only (Go)New structs, helpers, flag wiring; CreateContactV2/UpdateContactV2; isPhoneObjectArray dispatch; unit tests ≥ 95% diff coveragego test passes; flag OFF → legacy payload unchanged; flag ON → object array sent; buildCrmContactPhones correctly resolves BSUIDs from ChatData; Chat consumers untouched

2. Technical Design

Infrastructure Topology

Deployment topology

flowchart TB
subgraph cdp["contact-service (CDP / Go)"]
sync["SyncActionService\n.Sync() / .UpdateStaticDataCrm()"]
flag["IFeatureFlagService\n(Redis-cached, MongoDB-backed)"]
enq[["gocraft/work queue\n(Redis-backed)"]]
worker_create["CreateContactCrm worker"]
worker_update["UpdateContactCrm worker"]
client["QontakCrmClient\n.CreateContactV2 / .UpdateContactV2"]
end

subgraph crm["qontak.com (CRM / Ruby on Rails)"]
ep_create["POST /crm/centralized_contacts/create_contact"]
ep_update["POST /crm/centralized_contacts/update_contact"]
svc_update["Crm::CentralizedContacts::Update\nnormalize_phones()"]
model["crm_phones model\nbefore_update/before_destroy guard"]
db[(crm_phones\n+ bsuid column)]
end

redis_flag[(Redis\nfeature flag cache)]
mongo_flag[(MongoDB\nfeature flag store)]

sync -->|FeatureEnabled?| flag
flag <-->|read-through| redis_flag
flag <-->|source of truth| mongo_flag
sync -->|flag ON: enqueue V2 job| enq
sync -->|flag OFF: enqueue legacy job| enq
enq -->|consume| worker_create
enq -->|consume| worker_update
worker_create -->|isPhoneObjectArray dispatch| client
worker_update -->|isPhoneObjectArray dispatch| client
client -->|POST V2 payload| ep_create
client -->|POST V2 payload| ep_update
ep_create --> svc_update
ep_update --> svc_update
svc_update -->|normalize_phones| model
model --> db

Per-service responsibility

flowchart LR
subgraph cs["contact-service (CDP)"]
build["buildCrmContactPhones(contact)\n→ []ContactPhone"]
transform["transformCreateDeleteDataPayloadCrm()\n→ ContactCreateDeleteDataSentCrm"]
v2["QontakCrmClient\n.CreateContactV2 / .UpdateContactV2"]
dispatch["isPhoneObjectArray(payload) bool\n(consumer backward-compat dispatch)"]
end

subgraph crm_svc["qontak.com (CRM)"]
norm["normalize_phones(phones)\n→ [{phone_number, bsuid}]"]
guard["crm_phones model\nbefore_update immutability guard"]
end

build -->|"[]ContactPhone → ContactStaticDataChangesCrm"| v2
transform -->|"ContactCreateDeleteDataSentCrm"| v2
v2 -->|"HTTPS POST {phone:[{phone_number,bsuid}]}"| norm
dispatch -->|"routes to V2 or legacy"| v2
norm --> guard
ServiceResponsibility (this RFC)External calls
contact-service (CDP, Go)Build []ContactPhone from ChatData; flag-branch on crm_phone_object; enqueue V2 CRM jobs; isPhoneObjectArray dispatch in consumersPOST /crm/centralized_contacts/create_contact and /update_contact (CRM)
qontak.com (CRM, Rails)normalize_phones to read object or string per entry; write bsuid to crm_phones; immutability guardnone (inbound only)

Technical Decisions (ADR-format)

Decision 1: Phone payload shape — []ContactPhone object array for CRM path

Context CDP currently sends phones as a flat []string to CRM via ContactStaticDataChanges and ContactCreateDeleteDataSent. CRM receives this over the centralized-contacts API and stores phones in crm_phones, but has no BSUID linkage. The requirement is to carry bsuid per phone on the CRM sync path only.

Options considered

  • Option A — Parallel CRM-specific structs (ContactStaticDataChangesCrm, ContactCreateDeleteDataSentCrm) with []ContactPhone: create new Go structs for the CRM path; the existing ContactStaticDataChanges/ContactCreateDeleteDataSent used by the Chat path remain completely untouched. Pros: zero risk to Chat sync; strict separation of concerns; types are explicit. Cons: some duplication of struct fields.
  • Option B — Modify existing ContactStaticDataChanges to use interface{} phone field: unify both paths in one struct. Pros: less code. Cons: breaks Chat sync type safety; requires Chat consumers to handle both shapes; high regression risk.

Decision Option A — parallel CRM-specific structs.

Rationale The Chat sync path is a hard constraint: any change to shared structs risks breaking chat. Option A eliminates that risk by design. The struct field duplication is small (phone is the only field changing shape).

Consequences Two parallel struct hierarchies for CRM and Chat paths. The isPhoneObjectArray dispatch in workers routes to the correct enqueue based on payload shape.

Reversibility High — removing the CRM-specific structs and reverting to the shared struct is a one-PR change.


Decision 2: CRM backward compatibility — entry-type detection in normalize_phones

Context During rollout, the gocraft work queue may contain both legacy []string jobs (enqueued before flag enabled) and new []ContactPhone jobs (enqueued after). CRM must handle both safely without a queue flush or coordinated cutover.

Options considered

  • Option A — normalize_phones(phones) detects per entry: String{phone_number: entry, bsuid: nil}; Hash{phone_number: entry[:phone_number], bsuid: entry[:bsuid]}: pure consumer-side logic; no coordination required. Pros: CRM can be deployed first; both shapes work forever. Cons: slightly more complex consumer logic.
  • Option B — Versioned endpoint (/create_contact_v2, /update_contact_v2): new endpoints for the new shape; old endpoints for legacy. Pros: clean separation. Cons: requires CRM to maintain two endpoint implementations; more blast radius; CDP client must route to the correct version explicitly.

Decision Option A — entry-type detection in normalize_phones.

Rationale Option A is simpler, requires zero CRM endpoint changes, and makes CRM safe to deploy ahead of CDP. The isPhoneObjectArray dispatch on the CDP side already routes to the correct worker path; CRM does not need to distinguish.

Consequences normalize_phones has a type branch per entry. Unit test must cover: String-only payload (legacy), Hash-only payload (new), mixed payload (queue drain), blank phone filtered.

Reversibility High — normalize_phones is a pure internal helper.


Decision 3: Feature flag — per-company IFeatureFlagService (crm_phone_object)

Context The payload shape change must be rolled out incrementally, per company, with instant rollback capability. A global deploy-and-pray approach is ruled out given the CRM sync is a live production path.

Options considered

  • Option A — Per-company IFeatureFlagService.FeatureEnabled(ctx, "crm_phone_object", companySSOID) (Redis-cached, MongoDB-backed): existing flag infrastructure. Pros: consistent with all other per-company flags in contact-service; Redis cache gives sub-millisecond lookup; disabling the flag requires only a MongoDB write (cache expires). Cons: one flag check per sync operation (negligible given Redis cache hit rate).
  • Option B — Environment variable or deploy-time flag: all companies switch at once. Pros: simpler. Cons: no per-company rollback; not appropriate for a path touching live CRM data.

Decision Option A.

Rationale Per-company control is required for a safe rollout. The IFeatureFlagService interface is already used across contact-service — no new infrastructure needed. The constant FeatureFlagCrmPhoneObject = "crm_phone_object" follows the existing naming convention.

Consequences SyncActionService gets a SetFeatureFlagService(svc IFeatureFlagService) setter (dependency injection). Sync() and UpdateStaticDataCrm() branch on the flag. Flag OFF = zero behaviour change. Flag ON = new object payload enqueued.

Reversibility High — disable flag instantly per company; no CRM deployment or queue flush needed.


Decision 4: crm_phones.bsuid immutability guard

Context Once a crm_phones row is linked to a BSUID (from a WhatsApp channel subscription), that link must not change. Re-syncing the same contact must be a no-op for already-linked rows, not an overwrite.

Options considered

  • Option A — ActiveRecord before_update/before_destroy callbacks: abort the save if bsuid is changing from a non-nil value to a different value. Pros: enforced at the model layer regardless of call site; cannot be bypassed by a new consumer. Cons: any legitimate BSUID migration requires the guard to be temporarily disabled.
  • Option B — Application-level guard in Crm::CentralizedContacts::Update: check before writing. Pros: no model callback. Cons: bypassed by any other caller that writes directly to crm_phones.

Decision Option A — model-layer guard.

Rationale Model-layer enforcement is the only guarantee that no future consumer accidentally overwrites a BSUID. The guard fires a CRM log event on block (monitored as an alert threshold).

Consequences Re-syncing an already-linked phone is a safe no-op. Guard-aborted saves are monitored (alert: any occurrence post-deploy). TF-3154 must ship before any BSUID values are written.

Reversibility Medium — removing the guard requires a new migration + deploy.


Decision 5: BSUID sourcing — contact.ChatData (no extra DB query)

Context buildCrmContactPhones must resolve the BSUID for each phone number. The phone→BSUID mapping is in ChatData (PhoneNumber, Bsuid fields). The question is whether ChatData is already loaded on the Contact struct or requires a separate DB query.

Options considered

  • Option A — Use Contact.ChatData *[]ChatData already in memory: match on PhoneNumber; no extra query. Pros: zero latency overhead; consistent with existing pattern. Cons: assumes ChatData is always eagerly loaded on the sync path — must be verified.
  • Option B — Add a separate DB query to resolve BSUID per phone: Pros: explicit. Cons: N+1 risk; adds latency to sync path.

Decision Option A (per Confluence RFC) — with a verification gate.

Rationale The Confluence RFC states ChatData is already in memory. If verified, Option A is strictly better. If not, a targeted eager-load on the sync path is the fix — not a separate N+1 query.

Consequences buildCrmContactPhones iterates Contact.ChatData, matches PhoneNumber, returns Bsuid. No match → bsuid: null (normal for non-WhatsApp phones). Gate: verify that Contact.ChatData is always populated on the sync call path before executing chunk 2.

Reversibility High — if ChatData is not loaded, add an eager-load in the sync service; buildCrmContactPhones logic unchanged.


Decision 6: Deploy order — CRM-first

Context Both services must change. The order matters: if CDP sends the new payload before CRM handles it, syncs will fail. If CRM is updated first, it safely accepts both payload shapes, allowing CDP to enable its flag at any time without CRM coordination.

Options considered

  • Option A — TF-3154 (CRM schema) → TF-3156 (CRM consumer) → TF-3155 (CDP, flag OFF) → flag enable per company: CRM deployed first; CDP flag OFF by default.
  • Option B — Simultaneous cutover: both services deployed at the same instant. Pros: simpler rollout. Cons: in practice impossible without downtime; race between queue drain and CRM readiness.

Decision Option A (CRM-first).

Rationale TF-3156's normalize_phones handles both payload shapes from day one, making the CRM deployment safe to do independently of CDP. CDP's flag defaults to OFF, so deploying TF-3155 has zero behaviour change until the flag is explicitly enabled. This is the only approach that allows zero-downtime rollout.

Consequences TF-3154 must be deployed before TF-3156. TF-3156 must be deployed before TF-3155's flag is enabled. CDP workers in the queue before flag enablement still send legacy payloads — handled by normalize_phones.

Reversibility High — flip CDP flag OFF; reverts immediately.


Decision 7: Chat path isolation — parallel structs, shared struct untouched

Context ContactStaticDataChanges and ContactCreateDeleteDataSent are used by both Chat and CRM sync today. Chat sync must not be affected.

Options considered

  • Option A — New CRM-specific structs: ContactStaticDataChangesCrm and ContactCreateDeleteDataSentCrm embed or copy the shared fields, replacing Phone []string with Phone []ContactPhone. Chat continues to use the original structs.
  • Option B — Shared struct with interface{} phone field: one struct, two consumers. As per Decision 1, ruled out.

Decision Option A.

Rationale Isolation by construction. The original structs are never modified; Chat consumers cannot accidentally receive the new shape.

Consequences Two struct families maintained. An agent implementing this RFC must never modify ContactStaticDataChanges, ContactCreateDeleteDataSent, QontakChatClient, UpdateContactChat, or CreateContactChat.

Reversibility High.


Detail 2.0 — Repo Reading Guide

Repo Map

flowchart LR
subgraph go["contact-service (Go)"]
struct_old["ContactStaticDataChanges\nContactCreateDeleteDataSent\n(DO NOT MODIFY)"]
struct_new["ContactStaticDataChangesCrm\nContactCreateDeleteDataSentCrm\nContactPhone (NEW)"]
build["buildCrmContactPhones()\ntransformCreateDeleteDataPayloadCrm() (NEW)"]
sync_svc["SyncActionService\n.Sync() / .UpdateStaticDataCrm()"]
flag_svc["IFeatureFlagService"]
client["QontakCrmClient\n.CreateContactV2 / .UpdateContactV2 (NEW)"]
workers["CreateContactCrm worker\nUpdateContactCrm worker\n+ isPhoneObjectArray() dispatch (NEW)"]
end

subgraph rails["qontak.com (Rails)"]
update_svc["Crm::CentralizedContacts::Update\n+ normalize_phones() (NEW)"]
phones_model["crm_phones model\n+ bsuid column\n+ before_update/before_destroy guard (NEW)"]
end

sync_svc -->|flag check| flag_svc
sync_svc --> struct_new
struct_new --> build
sync_svc --> workers
workers --> client
client --> update_svc
update_svc --> phones_model

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
Gointernal/app/service/sync_action_service.goThe service to extend with flag branchSync() and UpdateStaticDataCrm() method shapes; existing CRM enqueue pattern [confluence — verify]
Gointernal/app/domain/contact/contact.go (or similar)Verify ChatData *[]ChatData field is present on Contact structContact struct layout; ChatData field presence + type [confluence — verify]
Gointernal/app/api/qontak_crm_client.go (or similar)The CRM API client to extendCreateContact/UpdateContact method signatures; HTTP request construction pattern [confluence — verify]
Gointernal/app/consumer/create_contact_crm.go (or similar)The consumer to add isPhoneObjectArray dispatch togocraft job struct + Process method shape [confluence — verify]
Gointernal/app/consumer/update_contact_crm.go (or similar)The consumer to add isPhoneObjectArray dispatch toSame as above [confluence — verify]
Gointernal/app/domain/contact/chat_data.go (or similar)Verify ChatData struct with PhoneNumber and Bsuid fieldsField names + types for buildCrmContactPhones [confluence — verify]
Gointernal/app/service/feature_flag_service.go (or similar)The flag service interface to injectIFeatureFlagService interface + FeatureEnabled(ctx, flag, companySSOID) signature [confluence — verify]
Gointernal/app/domain/contact/contact_static_data_changes.go (or similar)The struct NOT to modifyContactStaticDataChanges field layout for Chat path; use as template for Crm-specific clone [confluence — verify]
Railsapp/services/crm/centralized_contacts/update.rbThe service to extend with normalize_phonesClass structure; how phone attribute is written to crm_phones [confluence — verify]
Railsdb/migrate/*_add_bsuid_to_crm_phones.rb (TF-3154, to be created)The migration to writeRails migration conventions; before_update callback registration pattern [confluence — verify]

Reading Order for the Agent

  1. contact.go — confirm Contact.ChatData *[]ChatData exists and is populated on sync path.
  2. chat_data.go — confirm PhoneNumber string and Bsuid string (or *string) field names.
  3. contact_static_data_changes.go — copy field layout; this is the DO-NOT-MODIFY reference.
  4. sync_action_service.go — understand Sync() + UpdateStaticDataCrm() call sites for flag branching.
  5. feature_flag_service.go — confirm IFeatureFlagService interface + method signature.
  6. qontak_crm_client.go — HTTP request construction pattern for V2 methods.
  7. create_contact_crm.go + update_contact_crm.go — consumer shape for isPhoneObjectArray dispatch.
  8. crm/centralized_contacts/update.rb — Rails service class for normalize_phones placement.
  9. Any existing Rails migration on crm_phones — migration conventions for TF-3154.
  10. Makefile / .github/workflows/*.yml — build and test commands (Go: make test; Rails: bundle exec rspec).

Source Verification (provenance — verify before executing)

LayerAnchor / patternStatusEvidence
GoContact.ChatData *[]ChatData field[confluence — verify]Stated in Confluence RFC §1 Assumptions: "ChatData *[]ChatData with PhoneNumber and Bsuid"
GoIFeatureFlagService.FeatureEnabled(ctx, flag, companySSOID)[confluence — verify]Stated in Confluence RFC §1 Dependencies
GoContactStaticDataChanges struct (Chat — not for CRM)[confluence — verify]Confluence RFC §2.1, §3 Out of Scope
GoQontakCrmClient.CreateContact / .UpdateContact (existing)[confluence — verify]Confluence RFC §2.2 — V1 methods are the baseline for V2
Gogocraft work queue + Redis[confluence — verify]Confluence RFC §1 Dependencies
RailsCrm::CentralizedContacts::Update class[confluence — verify]Confluence RFC §2.1 table
Railscrm_phones table (existing)[confluence — verify]Confluence RFC §1 Dependencies / TF-3154
RailsPOST /crm/centralized_contacts/create_contact / /update_contact[confluence — verify]Confluence RFC §1 Dependencies
GoFeatureFlagCrmPhoneObject = "crm_phone_object" constant name[confluence — verify]Confluence RFC §2.2 feature flag spec

Every row tagged [confluence — verify] is a blocker: the executing agent must open and read the file before writing any code that depends on it. If any path or field name differs from what's listed, surface the discrepancy as an Open Question before proceeding.


Detail 2.1 — Architecture

End-to-end component diagram

flowchart TB
contact([Contact create/update event])
contact --> sync["SyncActionService\n.Sync() / .UpdateStaticDataCrm()"]
sync -->|FeatureEnabled crm_phone_object| flag{flag ON?}
flag -- "yes (V2 payload)" --> build["buildCrmContactPhones(contact)\n→ []ContactPhone from ChatData"]
flag -- "no (legacy payload)" --> legacy["ContactStaticDataChanges\nPhone: []string (unchanged)"]
build --> crm_struct["ContactStaticDataChangesCrm\nPhone: []ContactPhone"]
crm_struct --> enq_v2[["gocraft: enqueue V2 job"]]
legacy --> enq_leg[["gocraft: enqueue legacy job"]]
enq_v2 --> dispatch{"isPhoneObjectArray?"}
enq_leg --> dispatch
dispatch -- "yes" --> v2["QontakCrmClient\n.CreateContactV2 / .UpdateContactV2"]
dispatch -- "no" --> v1["QontakCrmClient\n.CreateContact / .UpdateContact (legacy)"]
v2 -->|"POST {phone:[{phone_number,bsuid}]}"| crm_ep["CRM centralized_contacts API"]
v1 -->|"POST {phone:[string]}"| crm_ep
crm_ep --> norm["Crm::CentralizedContacts::Update\nnormalize_phones()"]
norm --> guard["crm_phones model\nbefore_update guard"]
guard --> db[(crm_phones.bsuid)]

Data model

erDiagram
CONTACT {
string id PK
array chat_data "ChatData[]{PhoneNumber, Bsuid}"
string company_sso_id
}
CHAT_DATA {
string phone_number
string bsuid "nullable — null for non-WA"
}
CRM_PHONES {
int id PK
int contact_id FK
string phone_number
string bsuid "NEW — nullable; immutable once set"
datetime created_at
datetime updated_at
}
CONTACT ||..o{ CHAT_DATA : "has many (ChatData field)"
CONTACT ||..o{ CRM_PHONES : "synced to CRM"
CHAT_DATA |o..|| CRM_PHONES : "phone_number match → bsuid"

State machine — crm_phones.bsuid lifecycle

stateDiagram-v2
[*] --> null_bsuid: phone created (non-WA or before flag)
null_bsuid --> linked: first sync with BSUID present (flag ON)
linked --> linked: re-sync (guard: no-op if same BSUID)
linked --> guard_aborted: re-sync with different BSUID (guard fires — alert)
null_bsuid --> null_bsuid: re-sync without BSUID (flag OFF or non-WA)
linked --> [*]: contact deleted (before_destroy guard fires)

Detail 2.2 — Sequence Diagrams

Happy path — flag ON, new contact create

sequenceDiagram
participant E as Contact event
participant S as SyncActionService
participant F as IFeatureFlagService (Redis)
participant B as buildCrmContactPhones
participant Q as gocraft/work queue
participant W as CreateContactCrm worker
participant CRM as CRM centralized_contacts API
participant DB as crm_phones (Rails)

E->>S: Sync(contact, companySSOID)
S->>F: FeatureEnabled("crm_phone_object", companySSOID)
F-->>S: true
S->>B: buildCrmContactPhones(contact)
B->>B: iterate contact.ChatData; match PhoneNumber → Bsuid
B-->>S: []ContactPhone{{"081234","abc123"},{"089999",null}}
S->>S: build ContactStaticDataChangesCrm{Phone: []ContactPhone}
S->>Q: EnqueueJob(CreateContactCrmV2, payload)
Q->>W: dequeue
W->>W: isPhoneObjectArray(payload) → true
W->>CRM: POST /crm/centralized_contacts/create_contact\n{phone:[{phone_number:"081234",bsuid:"abc123"},{phone_number:"089999",bsuid:null}]}
CRM->>CRM: normalize_phones → [{phone_number,bsuid}]
CRM->>DB: INSERT crm_phones (phone_number="081234", bsuid="abc123")
CRM->>DB: INSERT crm_phones (phone_number="089999", bsuid=null)
DB-->>CRM: OK
CRM-->>W: 200 OK

Happy path — flag OFF (legacy, zero behaviour change)

sequenceDiagram
participant S as SyncActionService
participant F as IFeatureFlagService (Redis)
participant Q as gocraft/work queue
participant W as CreateContactCrm worker
participant CRM as CRM centralized_contacts API

S->>F: FeatureEnabled("crm_phone_object", companySSOID)
F-->>S: false
S->>S: build ContactStaticDataChanges{Phone: []string} (unchanged)
S->>Q: EnqueueJob(CreateContactCrm, legacy payload)
Q->>W: dequeue
W->>W: isPhoneObjectArray(payload) → false
W->>CRM: POST /crm/centralized_contacts/create_contact\n{phone:["081234","089999"]}
CRM->>CRM: normalize_phones → [{phone_number:"081234",bsuid:nil},{phone_number:"089999",bsuid:nil}]
CRM-->>W: 200 OK

Failure path — immutability guard fires on re-sync

sequenceDiagram
participant W as UpdateContactCrm worker
participant CRM as CRM centralized_contacts API
participant M as crm_phones model
participant Log as CRM log + alert

W->>CRM: POST /crm/centralized_contacts/update_contact\n{phone:[{phone_number:"081234",bsuid:"abc123"}]}
CRM->>CRM: normalize_phones
CRM->>M: UPDATE crm_phones SET bsuid="abc123" WHERE phone_number="081234"
M->>M: before_update: bsuid already set to "abc123" → same value → no-op (OK)
M-->>CRM: save succeeds
Note over M,Log: guard only aborts if new bsuid ≠ existing bsuid

W->>CRM: POST /crm/.../update_contact\n{phone:[{phone_number:"081234",bsuid:"xyz999"}]}
CRM->>M: UPDATE crm_phones SET bsuid="xyz999" WHERE phone_number="081234"
M->>M: before_update: bsuid changing "abc123"→"xyz999" → abort
M-->>CRM: ActiveRecord::RecordInvalid
CRM-->>W: 422 (or 200 with partial failure)
CRM->>Log: guard-aborted save logged → #cdp-ops alert

Detail 2.3 — Database Model (DDL / Rails)

TF-3154 — Rails migration (CRM, qontak.com)

# db/migrate/YYYYMMDDHHMMSS_add_bsuid_to_crm_phones.rb
class AddBsuidToCrmPhones < ActiveRecord::Migration[7.x]
def up
add_column :crm_phones, :bsuid, :string, null: true, default: nil
add_index :crm_phones, :bsuid, name: "index_crm_phones_on_bsuid"
end

def down
remove_index :crm_phones, name: "index_crm_phones_on_bsuid"
remove_column :crm_phones, :bsuid
end
end

Immutability guard — ActiveRecord callbacks on CrmPhone model (TF-3154)

# app/models/crm_phone.rb (new callbacks)
before_update :prevent_bsuid_change
before_destroy :prevent_bsuid_destroy

private

def prevent_bsuid_change
return unless bsuid_changed? && bsuid_was.present?
errors.add(:bsuid, :immutable, message: "cannot be changed once set")
throw :abort
end

def prevent_bsuid_destroy
return unless bsuid.present?
errors.add(:base, :bsuid_immutable, message: "cannot destroy a phone linked to a BSUID")
throw :abort
end

New Go structs (contact-service)

// internal/app/domain/contact/contact_phone.go (new file)
package contact

// ContactPhone carries a single phone entry for the CRM sync payload.
// The existing ContactStaticDataChanges struct (Chat path) is NOT modified.
type ContactPhone struct {
PhoneNumber string `json:"phone_number"`
Bsuid *string `json:"bsuid"` // nil for non-WhatsApp phones
}

// ContactStaticDataChangesCrm is the CRM-only clone of ContactStaticDataChanges
// with Phone as []ContactPhone instead of []string.
type ContactStaticDataChangesCrm struct {
// ... same fields as ContactStaticDataChanges except:
Phone []ContactPhone `json:"phone"`
}

// ContactCreateDeleteDataSentCrm is the CRM-only clone of ContactCreateDeleteDataSent.
type ContactCreateDeleteDataSentCrm struct {
// ... same fields except:
Phone []ContactPhone `json:"phone"`
}

Per-status lifecycle — crm_phones.bsuid

StateValueVisibilityTransitionGuard
Unsetnullinternallinked on first V2 sync with BSUIDnone
Linked"<bsuid>"internallinked (same value, re-sync no-op)before_update: abort if new value ≠ existing
Conflict attempted(blocked)CRM log alertstays linkedguard fires, save aborted

Detail 2.4 — APIs

Outbound (CDP → CRM)

EndpointMethodAuthRequest schema (V2)ResponseStatus codesChange type
/crm/centralized_contacts/create_contactPOSTservice token{..., phone: [{phone_number: string, bsuid: string|null}]}{...}200; 422 guard abortextended — phone field shape changed; endpoint unchanged
/crm/centralized_contacts/update_contactPOSTservice token{..., phone: [{phone_number: string, bsuid: string|null}]}{...}200; 422 guard abortextended — same

Legacy shape (flag OFF or queue drain):

EndpointRequest schema (V1 — unchanged)Reuse?
/crm/centralized_contacts/create_contact{..., phone: ["string"]}reused — no change
/crm/centralized_contacts/update_contact{..., phone: ["string"]}reused — no change

Inbound (none)

n/a — contact-service is the producer; no inbound webhook from CRM to CDP in this RFC.

Detail 2.5 — Async Job Spec

JobQueueTriggerInputRetryIdempotencyBackward compat
CreateContactCrm (legacy)gocraft/workSyncActionService.Sync() flag OFFContactCreateDeleteDataSentCrm or ContactCreateDeleteDataSentgocraft defaultjob-level; contact_id + company_sso_idunchanged
CreateContactCrmV2 (or isPhoneObjectArray dispatch on existing consumer)gocraft/workSyncActionService.Sync() flag ONContactCreateDeleteDataSentCrm with []ContactPhonegocraft defaultsamenew
UpdateContactCrm (legacy + V2 dispatch)gocraft/workSyncActionService.UpdateStaticDataCrm()ContactStaticDataChangesCrm or ContactStaticDataChangesgocraft defaultsameextended

The Confluence RFC describes isPhoneObjectArray as a dispatch helper inside the existing workers (not separate job types). The agent should confirm the existing consumer names and either (a) add the dispatch inside existing consumers or (b) register a new job name — this is an open question (OQ-5).

Detail 2.F — Responsibility Boundary

StepOwnerServiceTriggerOutputFailure handler
1. Detect feature flagCDP BEcontact-service (Go)sync eventflag ON/OFF booleanflag read error → treat as OFF (safe default)
2. Build []ContactPhone from ChatDataCDP BEcontact-service (Go)flag ON[]ContactPhoneChatData miss → bsuid: null (non-error)
3. Enqueue V2 CRM jobCDP BEgocraft queueflag ONjob enqueuedenqueue failure → standard gocraft retry
4. normalize_phones + write crm_phonesCRMqontak.com (Rails)job consumedcrm_phones.bsuid writtenguard abort → 422; monitored alert
5. Immutability enforcementCRMcrm_phones modelany write attemptabort on BSUID changealert on any occurrence

Detail 2.G — Concurrency Map

ResourceWritersCollision scenarioResolution
crm_phones.bsuid (one row)single CRM consumer per contacttwo concurrent syncs for same contactbefore_update guard; second write is no-op if same BSUID, aborted if different
Feature flag cache (Redis)Ops flag toggleflag flipped during active sync batchRedis TTL — in-flight jobs already have payload shape; new jobs use new flag state; safe either way

3. High-Availability & Security

Exports are async (gocraft work queue) and the flag check is Redis-cached, so the sync path adds minimal latency overhead. Worker failure is isolated per job; existing gocraft retry semantics apply.

Performance

  • Flag lookup: Redis hit < 1 ms; cache miss falls back to MongoDB (< 10 ms). No meaningful impact on sync throughput.
  • buildCrmContactPhones: O(n × m) where n = phones, m = ChatData entries. Both are small (< 20 items typical). No DB I/O.
  • No new index scans or joins on the sync critical path.

Monitoring & Alerting

SignalTargetAlert thresholdChannel
CRM sync success rate (create_contact + update_contact)> 99.5%< 99%#cdp-ops
Guard-aborted saves in CRM (before_update fires)0 per dayany occurrence#cdp-ops (critical)
CreateContactCrm + UpdateContactCrm queue depthstablegrowing unexpectedly#cdp-ops
BSUID population rate per companytrending up post-flag-ONflat after 24 h#cdp-ops (warning)
Chat flow error rate (regression check)0 new errorsany new error#cdp-ops (critical)

Logging

  • Go (CDP): slog structured fields: company_sso_id, contact_id, flag_value (crm_phone_object ON/OFF), phone_count, bsuid_count (count of non-nil BSUIDs).
  • Rails (CRM): existing Rails logger; guard abort logs BSUID conflict with contact_id and phone_number (no raw BSUID value — PII consideration).
  • PII: phone numbers are PII. Log counts only — never log raw phone number values or raw BSUID strings in production logs.

Security

  • AuthN/AuthZ: CRM centralized-contacts API is an internal service endpoint authenticated via service token. No user-facing surface.
  • Input validation: buildCrmContactPhones produces only entries where PhoneNumber is non-empty (mirror the existing blank-phone filter). normalize_phones filters blank entries on the CRM side.
  • BSUID source: sourced from Contact.ChatData — a CDP-internal, MongoDB-backed field. No user input path.
  • Multi-tenancy: companySSOID scopes every flag check and every CRM API call. Cross-company contamination not possible on this path.
  • Secret management: CRM API service token stored in contact-service config/secrets (existing pattern — verify config/load.go or equivalent).

Detail 3.A — Failure Mode Catalog

OperationFailureBehaviorAlert?
Flag lookup (Redis unavailable)FeatureEnabled returns errorTreat as false (flag OFF) — safe fallback; legacy payload sentWarning
buildCrmContactPhones (ChatData nil/missing)No BSUID matchesAll phones get bsuid: null — correct for non-WA phonesNo
CRM API 422 (guard abort)BSUID conflict on updateCRM save aborted; job may retry with same payload; guard fires againCritical
CRM API 5xxUpstream errorgocraft retry per existing policyWarning
gocraft queue unavailableEnqueue failsExisting error handling in SyncActionServiceExisting

Detail 3.B — Error Response Catalog

CodeSurfaceMeaningAction
CRM 422 (guard abort)CRM internalBSUID change attempted on locked rowLog + alert #cdp-ops; do not retry blindly — investigate
Flag service errorcontact-service logRedis/MongoDB unavailableFallback to legacy payload; log warning

4. Backwards Compatibility and Rollout Plan

Compatibility

  • CDP (contact-service): all new structs/helpers are additive. Existing ContactStaticDataChanges, ContactCreateDeleteDataSent, Chat consumers, and the Kafka paths are untouched. Flag default OFF = zero behaviour change on deploy.
  • CRM (qontak.com): normalize_phones handles both string and object entries per entry. The migration adding bsuid is nullable with no default — no data migration required for existing rows.
  • Queue: isPhoneObjectArray dispatch ensures jobs enqueued before and after flag enablement are both handled correctly without a queue drain.

Rollout Strategy

  • Flag: crm_phone_object — per-company, default OFF. Provisioned via IFeatureFlagService (MongoDB-backed, Redis-cached). Kill-switch: disable flag → CDP immediately reverts to legacy payload; no CRM deployment needed.
  • Deploy order: TF-3154 (CRM schema) → TF-3156 (CRM consumer) → TF-3155 (CDP, flag OFF) → flag enable per company.
  • Stages:
    • Stage 0: TF-3154 + TF-3156 merged and deployed to CRM production. Verify CRM handles both payload shapes (smoke test with string payload — should behave as before).
    • Stage 1: TF-3155 merged and deployed to CDP (flag OFF). Zero behaviour change. Smoke test: confirm Chat flows unaffected.
    • Stage 2: Enable crm_phone_object for 2–3 internal test companies. Monitor crm_phones.bsuid population rate and guard-aborted saves (target: 0 aborts). Run for 24 h.
    • Stage 3: Expand to CRM-migrating clients company by company. Monitor BSUID population rate trending upward. Confirm sync success rate > 99.5%.
    • Stage 4: GA (all companies). Monitor for 1 week; confirm no guard-aborted saves and no Chat regressions.
  • Stop conditions: any guard-aborted save spike; sync success rate < 99%; any Chat flow error introduced.
  • Rollback: disable crm_phone_object flag in MongoDB (Redis TTL expires in seconds). No CRM deployment. No queue flush. Already-enqueued legacy jobs continue to work.
  • Blast radius: worst case = flag-enabled companies only. Chat and non-flag companies unaffected.

Detail 4.A — Cross-Service Rollout Compatibility Matrix

ScenarioCDP (TF-3155)CRM (TF-3156)CRM schema (TF-3154)Works?Mitigation
BaselineOldOldOldyes
TF-3154 onlyOldOldNewyesnullable column with no default; no writes yet
TF-3154 + TF-3156OldNewNewyesnormalize_phones handles strings; no object payloads yet
All deployed, flag OFFNewNewNewyesCDP sends legacy; CRM handles strings
All deployed, flag ONNewNewNewyestarget state
CDP flag ON, CRM oldNew (flag ON)OldNewnonever reach this state — TF-3156 must precede flag enable
Rollback (flag OFF)NewNewNewyesCDP reverts to strings; CRM handles them

Detail 4.B — Configuration Contract

LayerEnv var / flagDefaultRequiredProvisionerSecret?
CDPcrm_phone_object (feature flag)OFFyesOps via flag serviceno
CDPCRM API service token / base URLyesexisting config (verify config/load.go)yes
Rails(none new)

Detail 4.C — Test Plan

LayerCommand (source — verify against repo)What it must prove
Go unitmake test (go test -race ./internal/...)buildCrmContactPhones: BSUID resolved from ChatData; nil for non-WA phones; empty ChatData; isPhoneObjectArray returns correct bool; flag branching in SyncActionService; ContactStaticDataChanges (Chat) struct unchanged
Go unitmake testtransformCreateDeleteDataPayloadCrm produces correct []ContactPhone
Go lintmake lintstatic analysis clean
Go buildmake buildcompiles
Rails spec (TF-3154)bundle exec rspec spec/models/crm_phone_spec.rbguard blocks BSUID overwrite; first-time write succeeds; re-sync with same BSUID is no-op; destroy blocked if BSUID set
Rails spec (TF-3156)bundle exec rspec spec/services/crm/centralized_contacts/update_spec.rbnormalize_phones: String path (legacy); Hash path (new + BSUID written); mixed payload; blank phone filtered
Rails buildbundle exec rails db:migrate:statusmigration applies and rolls back cleanly
Chat regressionmake test (Go) + bundle exec rspec (Rails) — filter Chat consumer specszero new failures on Chat create/update paths
Cross-service smokeManual Stage 2: send V2 payload to CRM staging; verify crm_phones.bsuid populatedend-to-end across API boundary; guard no-op on re-sync

Detail 4.D — Agent Execution Plan

OrderServiceTicketChunkFiles to create / modifyCommandsAcceptance criteria
1CRM (Rails)TF-3154Schema migrationdb/migrate/YYYYMMDDHHMMSS_add_bsuid_to_crm_phones.rb (new); app/models/crm_phone.rb (add before_update/before_destroy callbacks)bundle exec rails db:migrate + bundle exec rails db:rollbackmigration applies/rolls back cleanly; crm_phones.bsuid column exists; spec for guard passes
2CRM (Rails)TF-3154Guard testsspec/models/crm_phone_spec.rb (new or extend)bundle exec rspec spec/models/crm_phone_spec.rbfirst-time write ✓; re-sync same BSUID ✓ (no-op); re-sync different BSUID ✗ (guard aborts); destroy with BSUID ✗
3CRM (Rails)TF-3156normalize_phones helperapp/services/crm/centralized_contacts/update.rb (add normalize_phones(phones) private method; update phone attribute writes)bundle exec rspec spec/services/crm/centralized_contacts/update_spec.rbString-only payload → bsuid: nil; Hash payload → bsuid written; mixed → both handled; blank phone filtered
4Go (CDP)TF-3155New structs + constantinternal/app/domain/contact/contact_phone.go (new — ContactPhone, ContactStaticDataChangesCrm, ContactCreateDeleteDataSentCrm); add FeatureFlagCrmPhoneObject = "crm_phone_object" to constants filemake buildbuilds; types exported; ContactStaticDataChanges file unchanged (verify with git diff)
5Go (CDP)TF-3155buildCrmContactPhones helperinternal/app/service/sync_action_service.go (or new internal/app/service/crm_phone_builder.go)make test ./internal/app/service/...BSUID resolved from ChatData match; nil for no match; empty ChatData → all nil BSUIDs; blank phone excluded
6Go (CDP)TF-3155transformCreateDeleteDataPayloadCrm helpersame file as chunk 5 or new helper filemake test ./internal/app/service/...correct ContactCreateDeleteDataSentCrm produced; []ContactPhone populated
7Go (CDP)TF-3155SetFeatureFlagService + flag branching in SyncActionServiceinternal/app/service/sync_action_service.gomake test ./internal/app/service/...flag OFF → ContactStaticDataChanges (unchanged) enqueued; flag ON → ContactStaticDataChangesCrm (with []ContactPhone) enqueued
8Go (CDP)TF-3155CreateContactV2 / UpdateContactV2 on QontakCrmClientinternal/app/api/qontak_crm_client.go (extend)make build && make test ./internal/app/api/...V2 methods POST object phone array; V1 methods unchanged
9Go (CDP)TF-3155isPhoneObjectArray dispatch in consumersinternal/app/consumer/create_contact_crm.go and update_contact_crm.go (extend — add dispatch)make test ./internal/app/consumer/...object payload → routes to V2 client method; string payload → routes to V1; both paths produce correct CRM API call
10Go (CDP)TF-3155Full test suite + diff coverage(no new files)make test -coverprofile=coverage.out ./internal/... && go tool cover -func coverage.outall tests pass; diff coverage ≥ 95% on new code paths; make lint clean
11Cross-serviceTF-3155/3156Integration smoke (staging)(manual)Send V2 payload to CRM staging via CDP staging with flag ONcrm_phones.bsuid populated; guard no-op on re-sync; Chat consumers unaffected

Detail 4.E — Verification & Rollback Recipe

Pre-merge (in order):

  • CRM (TF-3154): bundle exec rails db:migrate + bundle exec rspec spec/models/crm_phone_spec.rb
  • CRM (TF-3156): bundle exec rspec spec/services/crm/centralized_contacts/update_spec.rb
  • CDP (TF-3155): make lint && make test && make build

Post-deploy signals:

  • CRM sync success rate stays > 99.5% (Datadog dashboard).
  • Zero guard-aborted saves in CRM logs after flag enablement.
  • crm_phones.bsuid populating for flag-ON companies (SQL: SELECT COUNT(*) FROM crm_phones WHERE bsuid IS NOT NULL).
  • Chat consumer error rate: 0 new errors.

Rollback (in order):

  1. Disable crm_phone_object flag for affected companies in MongoDB flag store (Redis TTL expires; CDP immediately sends legacy payload).
  2. No CRM deployment needed — normalize_phones continues handling legacy strings.
  3. Monitor CRM sync success rate and guard-abort count — should return to baseline within seconds.
  4. If CRM migration (TF-3154) must be rolled back: bundle exec rails db:rollback STEP=1 (removes bsuid column — only safe before any BSUIDs are written; confirm column is empty before rollback).

5. Concern, Questions, or Known Limitations

Resolved by design (closed in this RFC):

  • Chat path isolation → parallel CRM-specific structs; existing structs untouched (Decision 7).
  • CRM backward compatibility → normalize_phones entry-type detection (Decision 2).
  • BSUID sourcing → ChatData field already in memory; no extra DB query (Decision 5, pending verify).
  • Deploy order → CRM-first; CDP flag OFF default (Decision 6).

Open — requires decision before / at the relevant stage:

#QuestionAdopted defaultOwnerBlocks?
OQ-1Is Contact.ChatData always eagerly loaded on the sync path?Assumed YES (Confluence RFC §1 Assumptions) — must verify against live contact-service codeCDP BEYES — blocks chunk 5
OQ-2Should the delete flow also carry bsuid per phone for more precise CRM row matching?No — delete still uses flat []string (v1 scope)PM + CDPNo (deferred)
OQ-3Backfill strategy for existing crm_phones rows with blank BSUID?No automatic backfill; rows updated on next contact sync; proactive backfill job = follow-upPM + CDP InfraNo (deferred)
OQ-4Should crm_phone_object eventually become a global flag (all companies at once)?Per-company for now; global option after full validation at Stage 4PM + CDPNo
OQ-5Is isPhoneObjectArray a dispatch helper inside existing consumers, or should a new job name (CreateContactCrmV2) be registered?Dispatch inside existing consumers (per Confluence RFC §3 phase 3)CDP BEDecide in chunk 9
OQ-6What is the CRM API's response on guard-abort (422 or 200 with partial failure)?Assumed 422CDP BE + CRMConfirm before chunk 3
OQ-7What happens if crm_phone_object flag is enabled and a job in the queue is from before flag enable (old shape)?isPhoneObjectArray returns false → routed to V1 path (safe)CDP BENo

Known limitations:

  • No backfill for pre-existing crm_phones rows; BSUID only populated on create/update sync post-flag-enable.
  • Delete flow still uses flat []string — BSUID-per-phone delete matching is a v2 concern.
  • Phones added directly in CRM (not via WhatsApp) always have bsuid = null — by design.
  • Source verification in §2.0 is based on the Confluence DRAFT RFC (page 51208847634, version 4). Every anchor tagged [confluence — verify] must be confirmed against the live repo before the executing agent writes any code.

6. Comment logs

DateComment(s) FromAction Item(s)
2026-06-08CDP Engineering Team (Confluence DRAFT RFC)Original RFC created on Confluence (page 51208847634). Covers TF-3154/3155/3156 scope, feature flag strategy, deploy order, and monitoring
2026-06-22rfc-starter (rewrite from Confluence — this file)Reformatted to agent-execution-ready RFC: added YAML frontmatter, mermaid diagrams (topology, component, ER, state, sequence × 3), ADR-format decisions (7), Source Verification table (9 anchors — all tagged [confluence — verify]), Agent Execution Plan (11 chunks), Verification & Rollback Recipe. All [confluence — verify] anchors are blockers until confirmed against live repos

7. Ready for agent execution

  • conditional yes — the RFC is structurally complete and the execution plan is concrete. However, every Source Verification anchor is tagged [confluence — verify] because this RFC was written from the Confluence DRAFT RFC (page 51208847634), not from direct repo access. Before any agent executes a chunk, it must read each referenced file and confirm:
  1. OQ-1 gate (chunk 5 blocker): confirm Contact.ChatData *[]ChatData is present and eagerly loaded on the sync path in contact-service. If not, add an explicit eager-load as a pre-step.
  2. File path verification: open each anchor in the Reading Order (§2.0) and confirm the path, struct names, and method signatures match what's in the RFC. Update any that differ before writing code.
  3. OQ-5 decision (chunk 9): decide whether isPhoneObjectArray dispatch lives inside existing consumers or behind a new job name.
  4. OQ-6 confirm (chunk 3): confirm CRM API response code on guard-abort before writing the CRM consumer spec.

Execution-readiness gates:

  • §1 Schema Derivation — every business rule mapped to a field/endpoint/enforcement: yes.
  • Detail 1.C Per-Story / Per-Ticket Change Map — all 3 tickets, layer scope, artifacts, acceptance criteria: yes.
  • Repo Reading Guide (both layers) + contracts classified (reused/extended/new): yes.
  • Source Verification table — 9 anchors with provenance noted; all marked [confluence — verify] (not fabricated): yes.
  • Mermaid: deployment topology, per-service, component, ER, state machine, sequence (3 scenarios): yes.
  • DDL: Rails migration + Ruby callbacks + Go struct definitions: yes.
  • APIs: outbound (2 extended, V1 + V2), inbound (n/a): yes.
  • Async job spec: all 3 job types with retry + idempotency: yes.
  • Failure Mode Catalog + Rollback Catalog: yes.
  • Cross-service Rollout Compatibility Matrix + deploy order: yes.
  • Configuration Contract + flag coordination: yes.
  • Agent Execution Plan (11 chunks, files + commands + verifiable AC): yes.
  • Verification & Rollback Recipe (commands runnable; signals named): yes.
  • Open Questions surfaced (not silently omitted): yes (OQ-1 through OQ-7).

Suggested next step: run rfc-reviewer for a second-pass score. Priority gate: verify OQ-1 (ChatData eager-load) before opening any CDP PR.