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
| Field | Value | Notes |
|---|---|---|
| Status | RFC (DRAFT) | Human label; YAML status: carries linter enum draft |
| DRI | TBD | Assign CDP Engineering Tech Lead before first review |
| Team | cdp | Advisory squad slug |
| Author(s) | CDP Engineering Team | |
| Reviewers | CDP Tech Lead, CRM Tech Lead, Engineering Manager | Cross-squad sign-off required |
| Approver(s) | CDP Tech Lead, CRM Tech Lead | |
| Submitted Date | 2026-06-08 | ISO-8601 |
| Last Updated | 2026-06-22 | ISO-8601 |
| Target Release | 2026-Q2 | Quarter |
| Target Quarter | 2026-Q2 | Advisory |
| Delivery | not yet handed to delivery | Update once delivery/timeline.md exists |
| Related | Confluence RFC page 51208847634 | Source 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
- Overview (problem, goals, constraints, schema derivation, traceability)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Sequence Diagrams → DDL → APIs → async job spec)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (cross-service matrix, Agent Execution Plan, Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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
- CRM receives
[{phone_number, bsuid}]for flag-enabled companies;crm_phones.bsuidis populated correctly on create and update. - Immutability guard respected: zero guard-aborted saves in CRM logs on re-sync of already-linked phones.
- Instant rollback verified: disabling
crm_phone_objectflag reverts CDP to legacy flat-string payload with no CRM-side change needed. - Chat flows unchanged: zero new errors on Chat create/update paths after deployment.
- Diff coverage ≥ 95% on all new Go code paths (TF-3155).
- CRM sync success rate > 99.5% for create and update CRM jobs post-flag-enablement.
Out of Scope
- Chat sync payloads —
ContactStaticDataChanges,ContactCreateDeleteDataSent,QontakChatClient.UpdateContact,UpdateContactChat,CreateContactChatconsumers are not touched. - CRM delete flow — still uses flat
[]stringphone format; no change. - CDP ContactHandler / ContactApiHandler Kafka paths (
contact_create,contact_update) — out of scope. - Backfill of existing
crm_phonesrows with blank BSUID — post-GA follow-up job, not this RFC. - Direct CRM phone edits — phones added directly in CRM (not via WhatsApp Chat) will always have
bsuid = nullby design; noChatDataentry exists.
Related Documents
- Source Confluence DRAFT RFC: https://jurnal.atlassian.net/wiki/spaces/QON/pages/51208847634
- Jira TF-3154: CRM schema prerequisite (
bsuidcolumn + immutability guard oncrm_phones) - Jira TF-3155: CDP producer — new payload structs + feature-flag-gated sync
- Jira TF-3156: CRM consumer — backward-compatible
normalize_phones
Assumptions
ChatDatais always in memory: thecontact.Contactstruct loaded during sync already containsChatData *[]ChatDatawithPhoneNumberandBsuidfields — no additional DB query is needed to resolve BSUIDs. (Verify: confirmContactstruct loadsChatDataeagerly 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
bsuidcolumn oncrm_phonesand 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
isPhoneObjectArraydispatch in the consumers handles both without a queue flush.
Dependencies
| Dependency | Owner | Layer | Availability | Blocking? |
|---|---|---|---|---|
IFeatureFlagService.FeatureEnabled(ctx, flag, companySSOID) | CDP | contact-service (Go) | Exists — per-company, Redis-cached, MongoDB-backed | Reuse |
| gocraft work queue + Redis | CDP Infra | contact-service (Go) | Exists — used for all CRM sync workers | Reuse |
crm_phones table with bsuid column + immutability guard | CRM | qontak.com (Rails) | TF-3154 prerequisite — not yet deployed | YES — must merge before TF-3155/3156 |
Crm::CentralizedContacts::Update class | CRM | qontak.com (Rails) | Exists — handles update_contact inbound | Extend (TF-3156) |
POST /crm/centralized_contacts/create_contact | CRM | qontak.com (Rails) | Exists | Reuse |
POST /crm/centralized_contacts/update_contact | CRM | qontak.com (Rails) | Exists | Reuse |
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 / entity | Persisted as | Exposed via | Enforced 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 number | read-only from Contact.ChatData[].PhoneNumber + .Bsuid | n/a — serialization | buildCrmContactPhones() |
Non-WhatsApp phones (no ChatData match) carry bsuid: null | crm_phones.bsuid = null | payload bsuid: null | buildCrmContactPhones() — 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 error | ActiveRecord callback in crm_phones model |
| New payload shape is gated per company | Redis-cached flag crm_phone_object | IFeatureFlagService.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 drain | no storage change | CRM normalize_phones() detects String vs Hash per entry | CRM consumer |
CRM delete flow uses flat []string — unchanged | no change to ContactCreateDeleteDataSent | unchanged delete endpoint | n/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):
| Requirement | Service / artifact | RFC section |
|---|---|---|
| Each phone carries BSUID in CDP→CRM payload | ContactPhone struct, buildCrmContactPhones, ContactStaticDataChangesCrm | §2 Decision 1, §2.3, §2.4, §4.D chunk 2 |
| Feature flag gates new payload shape per company | FeatureFlagCrmPhoneObject const, SetFeatureFlagService, Sync() branch | §2 Decision 3, §4.D chunk 3 |
| CRM handles both payload shapes safely | normalize_phones(), entry-type detection | §2 Decision 2, §4.D chunk 6 |
crm_phones.bsuid immutability guard | before_update/before_destroy ActiveRecord callbacks | §2 Decision 4, §2.3 DDL, §4.D chunk 5 |
| Chat sync paths unchanged | out of scope; ContactStaticDataChanges not modified | §1 Out of Scope, §2.I scope boundaries |
| Rollback by flag disable only | IFeatureFlagService, no CRM deployment needed | §4 Rollout Strategy, §2 Decision 3 |
Reverse (RFC artifact → requirement):
| New artifact | Requirement 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) []ContactPhone | BSUID resolution from ChatData |
transformCreateDeleteDataPayloadCrm(...) helper | CRM-specific create/delete transform |
SetFeatureFlagService(IFeatureFlagService) on SyncActionService | Dependency injection for flag service |
CreateContactV2 / UpdateContactV2 on QontakCrmClient | V2 API calls with object phone shape |
isPhoneObjectArray(payload) bool dispatch helper | Backward-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
| Consumer | Authorization | Inbound source | Outbound effect | Cross-tenant? |
|---|---|---|---|---|
contact-service worker (gocraft job) | internal service — no user auth | gocraft queue (job enqueued by SyncActionService) | POST /crm/centralized_contacts/create_contact or /update_contact | no — company-scoped by companySSOID on each request |
Crm::CentralizedContacts::Update (Rails) | CRM internal service API (auth via service token) | POST /crm/centralized_contacts/update_contact | writes crm_phones.bsuid | no — crm_phones scoped per contact |
Detail 1.B — Key Decisions Summary
| # | Decision | Chosen option | §2 block |
|---|---|---|---|
| 1 | Phone payload shape | []ContactPhone{PhoneNumber, Bsuid} object array for CRM path | Decision 1 |
| 2 | CRM backward compatibility | Entry-type detection (Hash vs String) in normalize_phones | Decision 2 |
| 3 | Feature flag mechanism | Per-company IFeatureFlagService.FeatureEnabled("crm_phone_object", companySSOID) | Decision 3 |
| 4 | BSUID immutability | before_update/before_destroy ActiveRecord guard in crm_phones model | Decision 4 |
| 5 | BSUID sourcing | ChatData field already on loaded Contact struct — no extra DB query | Decision 5 |
| 6 | Deploy order | TF-3154 (CRM schema) → TF-3156 (CRM consumer) → TF-3155 (CDP) → flag enable | Decision 6 |
| 7 | Chat path isolation | Parallel CRM-specific structs; existing ContactStaticDataChanges untouched | Decision 7 |
Detail 1.C — Per-Story Change Map
| Ticket | Title | Layer scope | Changes | Acceptance criteria |
|---|---|---|---|---|
| TF-3154 | CRM schema + immutability guard | BE-only (Rails) | Add bsuid varchar to crm_phones; before_update/before_destroy callbacks; migration + rollback | Migration applies cleanly; guard blocks BSUID overwrite; guard allows first-time write; unit tests pass |
| TF-3156 | CRM consumer backward-compatible update | BE-only (Rails) | normalize_phones in Crm::CentralizedContacts::Update; handle both String and Hash entries; write bsuid from object shape | RSpec: String path (legacy) writes nil bsuid; Hash path writes bsuid; mixed payload; blank phone filtered; no regression on update_contact |
| TF-3155 | CDP producer — new payload + flag | BE-only (Go) | New structs, helpers, flag wiring; CreateContactV2/UpdateContactV2; isPhoneObjectArray dispatch; unit tests ≥ 95% diff coverage | go 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
| Service | Responsibility (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 consumers | POST /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 guard | none (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 existingContactStaticDataChanges/ContactCreateDeleteDataSentused 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
ContactStaticDataChangesto useinterface{}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 incontact-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_destroycallbacks: abort the save ifbsuidis 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 tocrm_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 *[]ChatDataalready in memory: match onPhoneNumber; no extra query. Pros: zero latency overhead; consistent with existing pattern. Cons: assumesChatDatais 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:
ContactStaticDataChangesCrmandContactCreateDeleteDataSentCrmembed or copy the shared fields, replacingPhone []stringwithPhone []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
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| Go | internal/app/service/sync_action_service.go | The service to extend with flag branch | Sync() and UpdateStaticDataCrm() method shapes; existing CRM enqueue pattern [confluence — verify] |
| Go | internal/app/domain/contact/contact.go (or similar) | Verify ChatData *[]ChatData field is present on Contact struct | Contact struct layout; ChatData field presence + type [confluence — verify] |
| Go | internal/app/api/qontak_crm_client.go (or similar) | The CRM API client to extend | CreateContact/UpdateContact method signatures; HTTP request construction pattern [confluence — verify] |
| Go | internal/app/consumer/create_contact_crm.go (or similar) | The consumer to add isPhoneObjectArray dispatch to | gocraft job struct + Process method shape [confluence — verify] |
| Go | internal/app/consumer/update_contact_crm.go (or similar) | The consumer to add isPhoneObjectArray dispatch to | Same as above [confluence — verify] |
| Go | internal/app/domain/contact/chat_data.go (or similar) | Verify ChatData struct with PhoneNumber and Bsuid fields | Field names + types for buildCrmContactPhones [confluence — verify] |
| Go | internal/app/service/feature_flag_service.go (or similar) | The flag service interface to inject | IFeatureFlagService interface + FeatureEnabled(ctx, flag, companySSOID) signature [confluence — verify] |
| Go | internal/app/domain/contact/contact_static_data_changes.go (or similar) | The struct NOT to modify | ContactStaticDataChanges field layout for Chat path; use as template for Crm-specific clone [confluence — verify] |
| Rails | app/services/crm/centralized_contacts/update.rb | The service to extend with normalize_phones | Class structure; how phone attribute is written to crm_phones [confluence — verify] |
| Rails | db/migrate/*_add_bsuid_to_crm_phones.rb (TF-3154, to be created) | The migration to write | Rails migration conventions; before_update callback registration pattern [confluence — verify] |
Reading Order for the Agent
contact.go— confirmContact.ChatData *[]ChatDataexists and is populated on sync path.chat_data.go— confirmPhoneNumber stringandBsuid string(or*string) field names.contact_static_data_changes.go— copy field layout; this is the DO-NOT-MODIFY reference.sync_action_service.go— understandSync()+UpdateStaticDataCrm()call sites for flag branching.feature_flag_service.go— confirmIFeatureFlagServiceinterface + method signature.qontak_crm_client.go— HTTP request construction pattern for V2 methods.create_contact_crm.go+update_contact_crm.go— consumer shape forisPhoneObjectArraydispatch.crm/centralized_contacts/update.rb— Rails service class fornormalize_phonesplacement.- Any existing Rails migration on
crm_phones— migration conventions for TF-3154. Makefile/.github/workflows/*.yml— build and test commands (Go:make test; Rails:bundle exec rspec).
Source Verification (provenance — verify before executing)
| Layer | Anchor / pattern | Status | Evidence |
|---|---|---|---|
| Go | Contact.ChatData *[]ChatData field | [confluence — verify] | Stated in Confluence RFC §1 Assumptions: "ChatData *[]ChatData with PhoneNumber and Bsuid" |
| Go | IFeatureFlagService.FeatureEnabled(ctx, flag, companySSOID) | [confluence — verify] | Stated in Confluence RFC §1 Dependencies |
| Go | ContactStaticDataChanges struct (Chat — not for CRM) | [confluence — verify] | Confluence RFC §2.1, §3 Out of Scope |
| Go | QontakCrmClient.CreateContact / .UpdateContact (existing) | [confluence — verify] | Confluence RFC §2.2 — V1 methods are the baseline for V2 |
| Go | gocraft work queue + Redis | [confluence — verify] | Confluence RFC §1 Dependencies |
| Rails | Crm::CentralizedContacts::Update class | [confluence — verify] | Confluence RFC §2.1 table |
| Rails | crm_phones table (existing) | [confluence — verify] | Confluence RFC §1 Dependencies / TF-3154 |
| Rails | POST /crm/centralized_contacts/create_contact / /update_contact | [confluence — verify] | Confluence RFC §1 Dependencies |
| Go | FeatureFlagCrmPhoneObject = "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
| State | Value | Visibility | Transition | Guard |
|---|---|---|---|---|
| Unset | null | internal | → linked on first V2 sync with BSUID | none |
| Linked | "<bsuid>" | internal | → linked (same value, re-sync no-op) | before_update: abort if new value ≠ existing |
| Conflict attempted | (blocked) | CRM log alert | stays linked | guard fires, save aborted |
Detail 2.4 — APIs
Outbound (CDP → CRM)
| Endpoint | Method | Auth | Request schema (V2) | Response | Status codes | Change type |
|---|---|---|---|---|---|---|
/crm/centralized_contacts/create_contact | POST | service token | {..., phone: [{phone_number: string, bsuid: string|null}]} | {...} | 200; 422 guard abort | extended — phone field shape changed; endpoint unchanged |
/crm/centralized_contacts/update_contact | POST | service token | {..., phone: [{phone_number: string, bsuid: string|null}]} | {...} | 200; 422 guard abort | extended — same |
Legacy shape (flag OFF or queue drain):
| Endpoint | Request 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
| Job | Queue | Trigger | Input | Retry | Idempotency | Backward compat |
|---|---|---|---|---|---|---|
CreateContactCrm (legacy) | gocraft/work | SyncActionService.Sync() flag OFF | ContactCreateDeleteDataSentCrm or ContactCreateDeleteDataSent | gocraft default | job-level; contact_id + company_sso_id | unchanged |
CreateContactCrmV2 (or isPhoneObjectArray dispatch on existing consumer) | gocraft/work | SyncActionService.Sync() flag ON | ContactCreateDeleteDataSentCrm with []ContactPhone | gocraft default | same | new |
UpdateContactCrm (legacy + V2 dispatch) | gocraft/work | SyncActionService.UpdateStaticDataCrm() | ContactStaticDataChangesCrm or ContactStaticDataChanges | gocraft default | same | extended |
The Confluence RFC describes
isPhoneObjectArrayas 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
| Step | Owner | Service | Trigger | Output | Failure handler |
|---|---|---|---|---|---|
| 1. Detect feature flag | CDP BE | contact-service (Go) | sync event | flag ON/OFF boolean | flag read error → treat as OFF (safe default) |
2. Build []ContactPhone from ChatData | CDP BE | contact-service (Go) | flag ON | []ContactPhone | ChatData miss → bsuid: null (non-error) |
| 3. Enqueue V2 CRM job | CDP BE | gocraft queue | flag ON | job enqueued | enqueue failure → standard gocraft retry |
4. normalize_phones + write crm_phones | CRM | qontak.com (Rails) | job consumed | crm_phones.bsuid written | guard abort → 422; monitored alert |
| 5. Immutability enforcement | CRM | crm_phones model | any write attempt | abort on BSUID change | alert on any occurrence |
Detail 2.G — Concurrency Map
| Resource | Writers | Collision scenario | Resolution |
|---|---|---|---|
crm_phones.bsuid (one row) | single CRM consumer per contact | two concurrent syncs for same contact | before_update guard; second write is no-op if same BSUID, aborted if different |
| Feature flag cache (Redis) | Ops flag toggle | flag flipped during active sync batch | Redis 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
| Signal | Target | Alert threshold | Channel |
|---|---|---|---|
CRM sync success rate (create_contact + update_contact) | > 99.5% | < 99% | #cdp-ops |
Guard-aborted saves in CRM (before_update fires) | 0 per day | any occurrence | #cdp-ops (critical) |
CreateContactCrm + UpdateContactCrm queue depth | stable | growing unexpectedly | #cdp-ops |
| BSUID population rate per company | trending up post-flag-ON | flat after 24 h | #cdp-ops (warning) |
| Chat flow error rate (regression check) | 0 new errors | any new error | #cdp-ops (critical) |
Logging
- Go (CDP):
slogstructured 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_idandphone_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:
buildCrmContactPhonesproduces only entries wherePhoneNumberis non-empty (mirror the existing blank-phone filter).normalize_phonesfilters blank entries on the CRM side. - BSUID source: sourced from
Contact.ChatData— a CDP-internal, MongoDB-backed field. No user input path. - Multi-tenancy:
companySSOIDscopes 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-serviceconfig/secrets (existing pattern — verifyconfig/load.goor equivalent).
Detail 3.A — Failure Mode Catalog
| Operation | Failure | Behavior | Alert? |
|---|---|---|---|
| Flag lookup (Redis unavailable) | FeatureEnabled returns error | Treat as false (flag OFF) — safe fallback; legacy payload sent | Warning |
buildCrmContactPhones (ChatData nil/missing) | No BSUID matches | All phones get bsuid: null — correct for non-WA phones | No |
| CRM API 422 (guard abort) | BSUID conflict on update | CRM save aborted; job may retry with same payload; guard fires again | Critical |
| CRM API 5xx | Upstream error | gocraft retry per existing policy | Warning |
| gocraft queue unavailable | Enqueue fails | Existing error handling in SyncActionService | Existing |
Detail 3.B — Error Response Catalog
| Code | Surface | Meaning | Action |
|---|---|---|---|
| CRM 422 (guard abort) | CRM internal | BSUID change attempted on locked row | Log + alert #cdp-ops; do not retry blindly — investigate |
| Flag service error | contact-service log | Redis/MongoDB unavailable | Fallback to legacy payload; log warning |
4. Backwards Compatibility and Rollout Plan
Compatibility
- CDP (
contact-service): all new structs/helpers are additive. ExistingContactStaticDataChanges,ContactCreateDeleteDataSent, Chat consumers, and the Kafka paths are untouched. Flag default OFF = zero behaviour change on deploy. - CRM (
qontak.com):normalize_phoneshandles both string and object entries per entry. The migration addingbsuidis nullable with no default — no data migration required for existing rows. - Queue:
isPhoneObjectArraydispatch 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 viaIFeatureFlagService(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_objectfor 2–3 internal test companies. Monitorcrm_phones.bsuidpopulation 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_objectflag 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
| Scenario | CDP (TF-3155) | CRM (TF-3156) | CRM schema (TF-3154) | Works? | Mitigation |
|---|---|---|---|---|---|
| Baseline | Old | Old | Old | yes | — |
| TF-3154 only | Old | Old | New | yes | nullable column with no default; no writes yet |
| TF-3154 + TF-3156 | Old | New | New | yes | normalize_phones handles strings; no object payloads yet |
| All deployed, flag OFF | New | New | New | yes | CDP sends legacy; CRM handles strings |
| All deployed, flag ON | New | New | New | yes | target state |
| CDP flag ON, CRM old | New (flag ON) | Old | New | no | never reach this state — TF-3156 must precede flag enable |
| Rollback (flag OFF) | New | New | New | yes | CDP reverts to strings; CRM handles them |
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|
| CDP | crm_phone_object (feature flag) | OFF | yes | Ops via flag service | no |
| CDP | CRM API service token / base URL | — | yes | existing config (verify config/load.go) | yes |
| Rails | (none new) | — | — | — | — |
Detail 4.C — Test Plan
| Layer | Command (source — verify against repo) | What it must prove |
|---|---|---|
| Go unit | make 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 unit | make test | transformCreateDeleteDataPayloadCrm produces correct []ContactPhone |
| Go lint | make lint | static analysis clean |
| Go build | make build | compiles |
| Rails spec (TF-3154) | bundle exec rspec spec/models/crm_phone_spec.rb | guard 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.rb | normalize_phones: String path (legacy); Hash path (new + BSUID written); mixed payload; blank phone filtered |
| Rails build | bundle exec rails db:migrate:status | migration applies and rolls back cleanly |
| Chat regression | make test (Go) + bundle exec rspec (Rails) — filter Chat consumer specs | zero new failures on Chat create/update paths |
| Cross-service smoke | Manual Stage 2: send V2 payload to CRM staging; verify crm_phones.bsuid populated | end-to-end across API boundary; guard no-op on re-sync |
Detail 4.D — Agent Execution Plan
| Order | Service | Ticket | Chunk | Files to create / modify | Commands | Acceptance criteria |
|---|---|---|---|---|---|---|
| 1 | CRM (Rails) | TF-3154 | Schema migration | db/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:rollback | migration applies/rolls back cleanly; crm_phones.bsuid column exists; spec for guard passes |
| 2 | CRM (Rails) | TF-3154 | Guard tests | spec/models/crm_phone_spec.rb (new or extend) | bundle exec rspec spec/models/crm_phone_spec.rb | first-time write ✓; re-sync same BSUID ✓ (no-op); re-sync different BSUID ✗ (guard aborts); destroy with BSUID ✗ |
| 3 | CRM (Rails) | TF-3156 | normalize_phones helper | app/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.rb | String-only payload → bsuid: nil; Hash payload → bsuid written; mixed → both handled; blank phone filtered |
| 4 | Go (CDP) | TF-3155 | New structs + constant | internal/app/domain/contact/contact_phone.go (new — ContactPhone, ContactStaticDataChangesCrm, ContactCreateDeleteDataSentCrm); add FeatureFlagCrmPhoneObject = "crm_phone_object" to constants file | make build | builds; types exported; ContactStaticDataChanges file unchanged (verify with git diff) |
| 5 | Go (CDP) | TF-3155 | buildCrmContactPhones helper | internal/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 |
| 6 | Go (CDP) | TF-3155 | transformCreateDeleteDataPayloadCrm helper | same file as chunk 5 or new helper file | make test ./internal/app/service/... | correct ContactCreateDeleteDataSentCrm produced; []ContactPhone populated |
| 7 | Go (CDP) | TF-3155 | SetFeatureFlagService + flag branching in SyncActionService | internal/app/service/sync_action_service.go | make test ./internal/app/service/... | flag OFF → ContactStaticDataChanges (unchanged) enqueued; flag ON → ContactStaticDataChangesCrm (with []ContactPhone) enqueued |
| 8 | Go (CDP) | TF-3155 | CreateContactV2 / UpdateContactV2 on QontakCrmClient | internal/app/api/qontak_crm_client.go (extend) | make build && make test ./internal/app/api/... | V2 methods POST object phone array; V1 methods unchanged |
| 9 | Go (CDP) | TF-3155 | isPhoneObjectArray dispatch in consumers | internal/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 |
| 10 | Go (CDP) | TF-3155 | Full test suite + diff coverage | (no new files) | make test -coverprofile=coverage.out ./internal/... && go tool cover -func coverage.out | all tests pass; diff coverage ≥ 95% on new code paths; make lint clean |
| 11 | Cross-service | TF-3155/3156 | Integration smoke (staging) | (manual) | Send V2 payload to CRM staging via CDP staging with flag ON | crm_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.bsuidpopulating 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):
- Disable
crm_phone_objectflag for affected companies in MongoDB flag store (Redis TTL expires; CDP immediately sends legacy payload). - No CRM deployment needed —
normalize_phonescontinues handling legacy strings. - Monitor CRM sync success rate and guard-abort count — should return to baseline within seconds.
- If CRM migration (TF-3154) must be rolled back:
bundle exec rails db:rollback STEP=1(removesbsuidcolumn — 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_phonesentry-type detection (Decision 2). - BSUID sourcing →
ChatDatafield 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:
| # | Question | Adopted default | Owner | Blocks? |
|---|---|---|---|---|
| OQ-1 | Is Contact.ChatData always eagerly loaded on the sync path? | Assumed YES (Confluence RFC §1 Assumptions) — must verify against live contact-service code | CDP BE | YES — blocks chunk 5 |
| OQ-2 | Should the delete flow also carry bsuid per phone for more precise CRM row matching? | No — delete still uses flat []string (v1 scope) | PM + CDP | No (deferred) |
| OQ-3 | Backfill strategy for existing crm_phones rows with blank BSUID? | No automatic backfill; rows updated on next contact sync; proactive backfill job = follow-up | PM + CDP Infra | No (deferred) |
| OQ-4 | Should crm_phone_object eventually become a global flag (all companies at once)? | Per-company for now; global option after full validation at Stage 4 | PM + CDP | No |
| OQ-5 | Is 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 BE | Decide in chunk 9 |
| OQ-6 | What is the CRM API's response on guard-abort (422 or 200 with partial failure)? | Assumed 422 | CDP BE + CRM | Confirm before chunk 3 |
| OQ-7 | What 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 BE | No |
Known limitations:
- No backfill for pre-existing
crm_phonesrows; 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-06-08 | CDP 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-22 | rfc-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:
- OQ-1 gate (chunk 5 blocker): confirm
Contact.ChatData *[]ChatDatais present and eagerly loaded on the sync path incontact-service. If not, add an explicit eager-load as a pre-step. - 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.
- OQ-5 decision (chunk 9): decide whether
isPhoneObjectArraydispatch lives inside existing consumers or behind a new job name. - 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-reviewerfor a second-pass score. Priority gate: verify OQ-1 (ChatDataeager-load) before opening any CDP PR.