Skip to main content

RFC β€” Contact source field: ID vs. label split, across contact-service + downstream consumers

Bucket item: A5 (see the bucket tracker) Β· Class: πŸ”΄ Expedite (reclassified 2026-07-21) Β· Jira Story: TF-3560 (execution Tasks: TF-3523, TF-3549) Β· Epic: TF-3500

All file:line citations below are paths inside the contact-service repo, not this docs repo.

Context / Problem​

The triggering symptom β€” chat-created contacts showing a human-readable name instead of an ID in Contact.Source β€” is already root-caused: MergeDataService.Create/ResolveData for app_name:"chat" does resolve Source to an ID correctly (contact-service/internal/app/service/merge_data.go:656-658,83-85). What's actually broken is everything downstream of that write: several contact-service read paths quietly prefer a redundant, never-resolved copy of source that chat also duplicates into custom_fields, and at least two systems outside this repo were built against that same human-readable assumption.

Three findings shape the whole plan and why it can't be a single contact-service PR:

  1. The chat/omnichannel producer isn't in this workspace. The app_name:"chat" payload that duplicates source into custom_fields is built in hub-core/hub-service/hub/hub-chat (per documents/code-repos.json), none of which are checked out alongside contact-service. Any change to what chat sends requires a cross-team ticket with that codebase's owners β€” this RFC cannot assume unilateral control over the producer.
  2. qontak.com's legacy CRM module deliberately depends on source staying human-readable today. Contact360::ParamsMapper#find_or_create_source (qontak.com/app/services/contact360/params_mapper.rb:79-85) and CdpIncomingContactMapper#source_status_attrs (cdp_incoming_contact_mapper.rb:153-167) read custom_fields["source"] first, fall back to the top-level source, and mint/match a Crm::Source row by that string. If contact-service's outbound webhook starts sending a bare UUID instead, this silently creates garbage Crm::Source rows named after UUIDs. This is a second cross-team dependency, not an internal implementation detail.
  3. The source field property itself wasn't always a protected default β€” dateable, not hypothetical. Between commits 9cdc54a and 7dfa165 (deployed 2026-02-04 to 2026-05-22), every company whose /init/ job ran created source as a plain custom field (IsDefault: false), not the protected default it is for /init/ runs after that. Note: this is a commit-deploy window, not a company-signup window β€” /init/ is on-demand and externally-triggered (see README.md), so a company that signed up long before or after this range could still have had its /init/ execute inside it. Company creation date is therefore the wrong lens for finding the affected set (see Field-property reconciliation for the corrected, data-driven targeting). A migration endpoint for exactly this (TF-2991, POST /field_properties/migrate-default-fields) was built the same day the default flipped β€” confirmed not yet run against the affected population (see Decisions). This is the single largest, cheapest lever in this whole plan β€” running it is Phase 0, before anything else here.

Given that, this RFC is sequenced so every internal fix ships safe on its own, and the external, coordinated changes are separate phases with explicit owners β€” not bundled as if they were contact-service PRs.

Cost of doing nothing​

Not a generic "tech debt" appeal β€” these are specific, already-observed mechanisms that get worse the longer this is deferred:

  1. Irreversible data loss risk grows daily for every company still is_default:false (the population the Feb–May commit window explains but does not fully enumerate β€” see Field-property reconciliation). A source field property still IsDefault: false can be deleted outright by an admin β€” DeleteFieldProperties only blocks deletion when IsDefault: true (contact-service/internal/app/service/field_properties/base.go:429-431). Deleting it makes every existing contact's Source/SourceID for that company permanently unresolvable, with no stored mapping left to recover the name from.
  2. The reported bug recurs indefinitely, per company, until Family A (Qontak-UI/S2S/OpenAPI) also resolves source β€” it currently writes raw, unresolved strings forever (create_contact_request.go:326). Each occurrence becomes another multi-repo support investigation, because nothing self-diagnoses or degrades loudly today.
  3. Search-by-source is already silently incomplete, right now, for most contacts β€” the filter only matches custom_fields.value (search_contact_request.go:264-277); any contact created without the redundant custom_fields duplicate (effectively all Family-A-created contacts today) is invisible to that filter, with no error surfaced to whoever is segmenting on it.
  4. Downstream CRM data keeps fragmenting. find_or_create_source mints/matches a Crm::Source row for whatever string it receives β€” a name, an ID, or empty, depending on which code path produced it β€” so every inconsistent value creates or matches a different row, and the eventual reconciliation grows, not shrinks.
  5. Marketing segments/broadcasts silently misfire today β€” segment_service.go:547,671-673 propagates whatever Source/SourceName a contact currently has; a segment defined as "Source = Whatsapp" quietly excludes contacts whose stored value happens to be an unresolved ID, under-targeting a broadcast with no error shown to the marketer.
  6. The free part of this fix has a closing window. Running the already-built TF-2991 endpoint against every currently-is_default:false company costs nothing today β€” no code, no backfill, no cross-team ask. Every contact created for one of those companies in the meantime is one more contact that needs the full per-contact backfill (Β§Backfill design) instead of being covered for free by a field-property flag flip.

Decisions​

Resolved (2026-07-21) β€” supersede the corresponding items originally raised as Open Questions:

  1. Family A resolution mode: use the existing resolver logic (auto-create-if-unmatched, chat-like) for Family A too β€” no separate strict-validation mode for now. Revisit only if abuse or dropdown-bloat surfaces in practice.
  2. TF-2991 run status: confirmed not yet run. Running it is now a required Phase 0 action, not an open check — and it must be scoped by live field_properties data state (is_default:false + field_type:"dropdown_select"), not by company creation date or the 9cdc54a→7dfa165 commit window, since /init/ timing isn't tied to signup date (see Field-property reconciliation).
  3. Contact.SourceID deprecation: removal is optional β€” downgraded to a [nice-to-have] cleanup item, not a required part of this migration.
  4. SourceName persistence: persist SourceName going forward (not live-resolve-only) β€” needed for external consumers (webhook/API responses) that expect a denormalized label. Staleness on dropdown rename is handled by extending OptionChangeConsumer (item 9) rather than by avoiding persistence.
  5. Chat producer's custom_fields["source"] duplication: incidental, not an intentional/defensive accommodation. Chat is expected to remove the duplicate entry once asked (Phase 3).

Resolved (2026-07-21, Sprint 2 planning) β€” staffing split, superseding the whole-squad co-ownership originally recorded in the bucket tracker:

  1. BE ownership split: Julio Jeffer owns the contact-service execution surface (Phases 0-2 β€” everything under "Proposed changes, by system Β§ contact-service" above); Ghozi owns the cross-team Phase 3 chat-producer coordination ask (asking the chat/omnichannel producer team to stop duplicating source into custom_fields, see "Β§ Upstream chat producer" above). Berlianto remains a squad co-owner / pull-in backup. This item is prioritized across the team relative to the bucket's other 🟑 Standard/βšͺ Intangible pull-in-fill items (A1-A4) β€” see the Sprint 2 plan. Still open: the FE (qontak-customer-fe) owner and whether Mobile has any scope here at all both still need confirming.

Target end state​

FieldMeaningPopulated by
Contact.Source (bson source)Stable per-company field_properties["source"].dropdown[].idEvery create/update path, via one shared resolver
Contact.SourceNameHuman-readable label for that ID, persisted at write time for external consumers; kept in sync on dropdown rename (item 9) rather than resolved live per-readEvery create/update path, via the shared resolver; refreshed by OptionChangeConsumer on rename
Contact.SourceID(deprecation optional β€” see Open Questions)β€”
Contact.CustomFields[key=="source"](legacy β€” retired in Phase 4)β€”
flowchart LR
subgraph Write["Any create/update path"]
W1["Family A: ContactHandler / ContactApiHandler / ContactOpenAPIHandler"]
W2["Family B: MergeDataService (chat + crm sync)"]
end
R["Shared SourceResolver<br/>(hoisted from resolveSource)"]
FP["field_properties['source'].dropdown<br/>(per-company, id+name pairs)"]
DB["Contact.Source = id<br/>Contact.SourceName = resolved label,<br/>persisted at write time"]

W1 --> R
W2 --> R
R <--> FP
R --> DB

subgraph Read["Any read path"]
L["List / Search / Webhook / Segment export"]
end
DB --> L
OC["OptionChangeConsumer<br/>(dropdown rename, item 9)"] -.->|"re-sync SourceName<br/>on rename"| DB
FP -.-> OC

Proposed changes, by system​

contact-service (this bucket's actual execution surface)​

#ChangeWhyFiles
1Hoist resolveSource into a shared resolver usable by both MergeDataService and ContactService.Family A and Family B must apply identical resolution logic or they drift again.merge_data.go:1085-1103 β†’ extract to internal/app/service/source_resolver.go
2Wire the resolver into Family A create/update (ContactHandler, ContactApiHandler, ContactOpenAPIHandler β€” Create, Update, SystemUpdate). Today it's pure passthrough.Without this, every non-chat entry point keeps writing raw strings forever.create_contact_request.go:326, update_contact.go:246
3Implement Family A resolution using the existing resolver logic (auto-create-if-unmatched, chat-like) β€” the same mode resolveSource already applies for Family B; no separate strict-validation path for now.Decided: reuse the existing logic rather than build a second mode (see Decisions); revisit only if abuse/dropdown-bloat surfaces in practice.merge_data.go:1085-1103 (shared resolver from item 1)
4Stop the response serializer preferring the custom_fields duplicate. getSourceFromCustomFields() currently drives the source JSON key for every Family A response ahead of the correctly-resolved Contact.Source.This is the actual visible symptom β€” the DB record is already correct, the response isn't.create_serializer.go:210-225, used at :341, :661, :724
5Stop the outbound webhook builder doing the same override. Gate behind qontak.com coordination below β€” do not ship ahead of it.Same bug, different exit door β€” external webhook subscribers currently receive the raw label, not the ID.webhook_delivery_interface.go:855-867
6Re-point the search-by-source filter at Contact.Source (today it matches custom_fields.value exclusively).Contacts without the custom_fields duplicate are silently unsearchable by source today, independent of this migration. FE already computes both {id, name} per option but only sends .name β€” a clean two-sided fix (see FE Β§3).search_contact_request.go:264-277
7Add resolution-failure observability. resolveSource's swallowed error is currently invisible (log line only, no metric).Directly caused the confusion in the original investigation β€” the raw-text fallback is fine as resilience, but must not be silent.merge_data.go:1091
8Make "field property not found" self-healing β€” lazily run the equivalent of GenerateDefaultFields for source if ResolveOrCreateDropdownOption fails because the property doesn't exist yet.Removes an entire failure class instead of just logging it.resolve.go:28-36
9Fix stale SourceName after a dropdown rename. OptionChangeConsumer only rewrites custom_fields entries today, never the system Contact.Source/SourceName fields. Decided: keep persisting SourceName (needed for external consumers, see Decisions) β€” extend OptionChangeConsumer to also propagate a rename into every affected contact's system Contact.SourceName, the same way it already does for custom_fields.Otherwise every dropdown rename silently desyncs the persisted SourceName on every chat/CRM-origin contact, with no propagation job able to fix it.option_change.go:105-127, get_contact.go:87-98
10Audit CRM-origin Source values for referential integrity β€” CRM sends an opaque ID it minted independently (crm_source&.cdp_option_id); verify these actually exist in the company's field_properties["source"].dropdown.Read-only audit, not a rewrite β€” we don't control the correct value on mismatch.β€”
11Re-render Source as SourceName in activity-log diffs. Source isn't in either skipFieldNames list, so audit trail entries will read "Source changed from <uuid> to <uuid>" once Family A also resolves to IDs.Unreadable to a human reviewing history.merge_data.go:790-820, update_contact.go:306-324

qontak.com (legacy CRM monolith) β€” cross-team, required before item 5 above ships​

Contact360::ParamsMapper#find_or_create_source and CdpIncomingContactMapper#source_status_attrs must stop reading custom_fields["source"] and stop treating the top-level source as a display label. They switch to reading source_name (once contact-service's webhook reliably sends it) for the Crm::Source.source display value, and populate Crm::Source.cdp_option_id from the now-reliable source (ID) field — closing the loop with the CRM→CDP direction, which already sends cdp_option_id today (crm/centralized_contacts/params_mapper.rb:55).

Not in scope for a contact-service PR β€” needs its own ticket, owner, and review from whoever maintains qontak.com's Contact360/CRM module.

Longer-term, non-goal note: Crm::Source (qontak.com) and field_properties["source"].dropdown (contact-service) are two independently-alive registries of "source," loosely and one-directionally synced today. Fully unifying them is a bigger cross-system project, scoped separately.

Upstream chat producer (hub-core/hub-service/hub-chat β€” location unconfirmed)​

  • File a cross-team ticket β€” this workspace doesn't contain the code that builds the app_name:"chat" payload, so it can't be changed here.
  • Decided: the custom_fields: [{key:"source", ...}] duplication is incidental, not an intentional/defensive accommodation (see Decisions) β€” chat is expected to remove it once asked.
  • Once contact-service's read paths (items 4, 5, 6 above) are live and stable through a bake period, ask the chat producer team to stop sending the duplicate custom_fields entry β€” not before, or it breaks qontak.com's CRM module and any not-yet-migrated read path silently.

qontak-customer-fe (Customer 360 frontend)​

#ChangeFiles
1Contact list "Source" column reads customer.source directly today β€” switch to customer.source_name for display.ListPage.vue:206,479-504, ListTable.vue:337-348 (formatCustomerData)
2Contact detail panel's generic getPropertyValue() falls through to the top-level field for any is_default field, including "source." Switch display to source_name; keep source (ID) for the edit-mode dropdown's v-model.CustomerDetails.vue:613-638
3Source filter chips currently send .name as the query param. fetchSourceOptions() already computes .id alongside .name β€” switch onSourceChange/getSelectedSourcesCode to send .id, matching the backend fix above.ListPage.vue:638-671,373,429-433
4No cross-page cache invalidation when a dropdown option is renamed in Field Properties settings β€” pre-existing gap; still relevant even with SourceName persisted, since a rename now depends on the backend's OptionChangeConsumer propagation (item 9) reaching this contact before the FE cache refreshes. Worth a follow-up ticket, not blocking.ModalFieldDetails.vue, FieldFormStore.ts:165-177

Sequencing constraint: FE items 1–3 must ship in the same release train as contact-service items 4 and 6, or behind a shared feature flag β€” flipping only one side breaks the other.

Data migration plan (existing contacts)​

Field-property reconciliation β€” run before any contact backfill​

Everything below assumes a company's field_properties["source"] is the canonical, protected, dropdown-shaped field property. Confirmed history, from git:

WindowWhat /init/ created for sourceCommit
Before 2026-02-04Nothing β€” source wasn't in DefaultFieldsβ€”
2026-02-04 β†’ 2026-05-22GenerateDefaultFields, but IsDefault: false β€” plain custom field, deletable9cdc54a (TF-2014/TF-2016)
After 2026-05-22IsDefault: true directly, correct from day one7dfa165 (TF-2954)

A migration tool for exactly this bucket already exists β€” POST /api/v1/field_properties/migrate-default-fields (TF-2991, sync_field_properties_handler.go:497-546 β†’ MigrateDefaultFieldPropertiesForCompanies, service/field_properties/migrate_default_fields.go:17-72 β†’ UpdateIsDefaultByCompanyAndName, repository/field_properties/migrate_default_fields.go:10-34). Confirmed not yet run against the affected population (see Decisions). This is the single highest-priority action in this whole plan β€” run it before anything else proceeds.

Target by live data state, not by company creation date or the historical commit window. The 9cdc54a→7dfa165 window above explains why this bucket of companies exists, but a company's /init/ run date is not the same as its signup date (/init/ is on-demand and externally-triggered — see README.md), so there is no reliable way to enumerate the affected companies from a creation-date range. The only correct source of truth is the current field_properties data itself:

// 1. Detection/verification β€” run against field_properties directly, no date filter at all:
db.field_properties.find(
{ type: "contact", name: "source" },
{ company_sso_id: 1, is_default: 1, field_type: 1, dropdown: 1 }
)

// 2. Build the exact input for TF-2991 from the live "Adopt" bucket below β€”
// every company_sso_id currently is_default:false + dropdown_select, regardless of
// when that company signed up or when its /init/ ran:
db.field_properties.find(
{ type: "contact", name: "source", is_default: false, field_type: "dropdown_select" },
{ company_sso_id: 1, _id: 0 }
).toArray().map(d => d.company_sso_id)

This inherently covers every affected company, regardless of when it signed up — including any company whose creation date falls outside the 9cdc54a→7dfa165 window but whose /init/ still happened to execute inside it — rather than relying on an assumed, date-derived cohort that a signup-date filter would get wrong in either direction (missing some in-window signups whose /init/ ran late, and wrongly including out-of-window signups whose /init/ never ran or ran early).

flowchart TD
Q["field_properties query per company<br/>(no date/creation filter)"] --> X1{"doc exists?"}
X1 -->|no| M["Missing β€” /init/ never ran, or ran before source<br/>joined DefaultFields. Bootstrap via GenerateDefaultFields<br/>(self-healing create-if-missing, item 8 above)"]
X1 -->|yes| X2{"is_default == true?"}
X2 -->|yes| OK["Already canonical β€” no action"]
X2 -->|no| X3{"field_type is dropdown_select?"}
X3 -->|yes| Adopt["Run POST /field_properties/migrate-default-fields (TF-2991)<br/>for this company_sso_id β€” data-driven, not date-scoped"]
X3 -->|no| Incompatible["Rare: admin's own unrelated custom field named 'source'.<br/>Rename it, then bootstrap a fresh default field"]
  • is_default:false + dropdown_select bucket: no new code β€” run the existing endpoint against every company_sso_id the query above returns, not a company-creation-date-derived list. Seeding was unconditional, so these already have the correct 20 locked options; flipping is_default is all that's missing. Default expectation: "flip it," not "manually review every one" (spot-check only).
  • Guardrail β€” do not pre-filter by anything other than this query. UpdateIsDefaultByCompanyAndName (repository/field_properties/migrate_default_fields.go:10-34) matches only on {company_sso_id, name, type:"contact"} β€” it does not check is_default or field_type itself before setting is_default:true. If a company_sso_id is passed in without first confirming field_type:"dropdown_select" (e.g. sourced from a creation-date list instead of the query above), the endpoint will silently flip is_default:true on the rare "incompatible" bucket's unrelated custom text field too. It is safe to over-include already-is_default:true companies (idempotent no-op), but never safe to include companies purely because they fall in a date range.
  • Batching. MigrateDefaultFieldPropertiesForCompanies loops over company_sso_ids synchronously, two Mongo updates per company, with no pagination or async support (service/field_properties/migrate_default_fields.go:17-72). Chunk the full list from the query above into batches (e.g. a few hundred company_sso_ids per call) rather than one request for the entire population.
  • "Doc missing" bucket: two populations, same resolution (bootstrap fresh) β€” companies whose /init/ ran before source joined DefaultFields, and any company whose /init/ never ran or failed regardless of era.
  • "Incompatible field_type" bucket (e.g. single_line_text): genuinely rare β€” an admin's own custom field named source. Needs a rename (standard UpdateFieldProperties API/admin UI) plus product/CS sign-off, since it changes what that company's admins see.
  • Sequencing: this reconciliation must be confirmed complete before the contact backfill runs, and before Family A resolution goes live for a given company; both assume exactly one correct, dropdown-shaped, protected source field property.

Data states in production today​

(Assumes the field-property reconciliation above has already run for the company in question.)

flowchart TD
A["State A β€” chat contacts created after the resolver deploy,<br/>resolution succeeded: Source=id, SourceName=name"] -->|no action| Done1["already correct"]
B["State B β€” chat contacts, resolution silently fell back<br/>(Mongo hiccup, 150-item cap, etc.): Source=raw name, SourceName=''"] -->|backfill| Fix
C["State C β€” chat contacts created before the resolver deploy:<br/>Source=raw name always"] -->|backfill, likely largest bucket| Fix
D["State D β€” CRM-sync contacts: Source=opaque ID minted by CRM's own UI"] -->|audit only, don't rewrite| Audit["flag mismatches for qontak.com follow-up"]
E["State E β€” Family A contacts (Qontak UI / S2S / OpenAPI):<br/>Source=raw passthrough, never resolved by any version of the code"] -->|backfill + requires Family A code fix first| Fix
F["State F β€” Source empty, but custom_fields has a 'source' entry"] -->|promote + resolve, then backfill| Fix
Fix["Backfill: resolve name -> id via field_properties dropdown,<br/>auto-creating the option if genuinely new"]

Backfill design​

  • Batch by company, not by contact. Fetch each company's field_properties["source"].dropdown once, build an in-memory case-insensitive/trimmed nameβ†’ID map (reuse the matching rules in matchDropdownOption, resolve.go:91-103), then bulk-$set contacts in batches (e.g. 500) via UpdateMany/bulk-write. Do not call the per-contact resolver serially.
  • Scope per company: contacts where Source != "" and Source doesn't already match an existing option's ID. Treat the value as a name and resolve/create against the dropdown, exactly like ResolveOrCreateDropdownOption does for live chat traffic.
  • State F: for Source == "" contacts with a custom_fields["source"] entry, promote that value into Source before resolving β€” retires the read-time-only fallback in get_contact.go:76-85 once done.
  • State D: audit-only β€” flag CRM-origin Source values with no matching dropdown option instead of rewriting them; we don't know the "correct" ID on a mismatch.
  • Pre-flight cap check: if backfilling a company would push its source dropdown past MaxDropdownItems (150, base.go:335), halt and flag that company for manual review rather than let per-contact resolution start failing mid-run.
  • Idempotent & dry-run capable. Re-running is a no-op for already-migrated contacts; support a dry-run mode reporting counts/samples without writing.
  • Backup before writing β€” snapshot the affected subset (or full mongodump) before the first production run; this touches the entire contacts collection across every company.
  • Staged rollout β€” pilot on low-risk/internal companies first, verify end-to-end (backend + FE) manually, then expand in batches.
  • Reporting β€” reuse the aggregate-report shape already established in migrate_default_fields.go (per-company success/failure, total updated).
  • Expose as an admin migration entry point under /api/v1, consistent with this repo's existing BasicAuth-protected /api/v1 surface (e.g. /api/v1/field_properties/migrate-default-fields, internal/server/rest_router.go:296-394) β€” not baked into request-time code paths.

Sequencing relative to code changes​

  1. Field-property reconciliation first, per company β€” the backfill resolver is only as correct as the field property it resolves against.
  2. Run the contact backfill after the read-path fixes (serializer, webhook, search) are deployed and after Family A resolution ships β€” otherwise freshly-backfilled IDs display as raw UUIDs in an FE that hasn't updated yet, and Family A keeps writing fresh raw-string contacts that immediately re-populate the "needs backfill" bucket, turning this into a moving target.

Execution plan (phased rollout)​

flowchart LR
P0["Phase 0<br/>Query field_properties for is_default:false<br/>+ dropdown_select, run TF-2991 against every hit"] --> P1["Phase 1<br/>Internal, no coordination needed"]
P1 --> P2["Phase 2<br/>Backend + FE, same release train"]
P2 --> P3["Phase 3<br/>Cross-team: qontak.com + chat producer"]
P3 --> P4["Phase 4<br/>Cleanup, after bake period"]

Phase 0 β€” do first, already-built, confirmed not yet run

  • Query field_properties for every company_sso_id where type:"contact", name:"source", is_default:false, field_type:"dropdown_select" β€” no date or company-creation filter (see Field-property reconciliation for why date-based targeting is unreliable here).
  • Run POST /field_properties/migrate-default-fields (TF-2991) against that full list, batched β€” no code changes required, resolves the field-property side of the bug for every affected company, not just an assumed cohort.

Phase 1 β€” ship anytime, internal only

  • Hoist the shared resolver; wire into Family A using the existing auto-create-if-unmatched logic (see Decisions β€” no separate strict-validation mode).
  • Add the resolution-failure metric and self-healing field-property creation.
  • Extend OptionChangeConsumer to propagate dropdown renames into the persisted Contact.SourceName (item 9), since SourceName is now decided to be persisted, not live-resolved.
  • Build the field-property audit query for the "missing"/"incompatible" buckets, and the contact backfill tooling β€” built and tested, not yet run.

Phase 2 β€” backend + FE together

  • Remove the serializer's custom_fields preference; re-point the search filter at Contact.Source; FE switches filter chips to IDs and list/detail display to source_name.
  • Run the field-property reconciliation, then the staged contact backfill β€” before or alongside this phase, not after.

Phase 3 β€” cross-team, needs external sign-off

  • qontak.com updates Contact360::ParamsMapper/CdpIncomingContactMapper to consume source_name and populate cdp_option_id.
  • Once Phase 2 has baked (suggest 2–4 weeks of monitoring) and qontak.com's update is live, remove the webhook builder's custom_fields override.
  • Ask the chat producer team to stop sending the duplicate custom_fields["source"] entry β€” expected to be straightforward since the duplication is incidental, not defensive (see Decisions).

Phase 4 β€” cleanup, after Phase 3 bakes

  • Optional one-off job to strip lingering custom_fields entries keyed "source" β€” low priority, skip if not worth the write cost.
  • Retire get_contact.go's CustomFields β†’ Source promotion fallback once backfill is confirmed complete.
  • Optional: Contact.SourceID deprecation β€” not required for this migration; if pursued, re-verify via real API access logs first, not just static analysis.

Risks & rollback​

  • Backfill risk (highest blast radius): touches the entire contacts collection across every company. Mitigated by: batch-by-company design, idempotency, dry-run mode, pre-flight MaxDropdownItems cap check, mandatory backup/snapshot before the first production run, and staged (pilot-first) rollout β€” see Backfill design. Rollback is restoring from the pre-run snapshot; there is no cheap partial-undo once IDs are written.
  • Family A resolution-mode risk (accepted): Family A now auto-creates a dropdown option for any unmatched string, same as chat β€” a third-party OpenAPI/S2S client can mint new dropdown options just by sending a novel string. Decided to accept this and reuse the existing logic rather than build a second strict-validation mode (see Decisions); revisit if abuse or dropdown-bloat surfaces (the MaxDropdownItems cap in Backfill design is the existing backstop).
  • SourceName persistence risk (accepted): persisting reintroduces a staleness class of bug on dropdown rename. Mitigated by extending OptionChangeConsumer to propagate renames into the system Contact.SourceName (item 9) rather than leaving it orphaned like today; monitor that propagation the same way as the resolution-failure metric (item 7) in case it lags or fails.
  • Cross-team sequencing risk: shipping contact-service items 5 (webhook override removal) or the chat producer's custom_fields removal ahead of their respective external dependents (Β§ qontak.com, Β§ chat producer) breaks those systems silently, per the findings in Context/Problem. Both are explicitly gated behind external sign-off in the phased plan above β€” do not let a Phase-2 release accidentally bundle a Phase-3 change.
  • Orphaned-ID risk is bounded, not eliminated: locked default dropdown options (the 20 seeded channel names) can't be renamed/deleted, but non-locked options can be deleted by an admin, orphaning any Contact.Source ID pointing at them. GetDropdownOptionName already degrades gracefully (empty match, not a crash) β€” acceptable, called out as intended behavior.

Open Questions​

All previously [critical]/[important] items have been resolved β€” see Decisions. What remains is non-blocking:

  1. [nice-to-have] Contact.SourceID fate: removal is optional, not required by this migration. No confirmed consumer was found in FE or in the other repos searched, but that isn't proof no one uses it β€” it's a public API field. If removal is pursued later, re-check real API access logs first, not just static analysis.
  2. [nice-to-have] Bucket classification for this item once it's appended to the tracker: this plan is far larger than the other four items in this bucket (cross-team, data-migration risk, multiple phases) β€” worth a deliberate Class-of-Service call (🟑 Standard vs. πŸ“… Fixed-Date vs. flagging for graduation to its own standard initiative, per the bucket's "big migration β†’ its own initiative" guard already used for the gocraft/work item) rather than defaulting silently.

Handoff note​

This RFC file is step 1 of the bucket's "how the bucket grows" process. Not yet done, pending confirmation:

  • Append a tracker row to the ## Stories table (next free id, Class, DRI, Status: backlog, today's date, Scope, staffing, Jira Story key, summary linking this file).
  • Mint the Jira Story under Epic TF-3500 and mirror its key here and in the tracker row.
  • Mirror staffing into the roadmap Implementors/QA and capacity.md Allocation.
  • Refresh the tracker's progress rollup.

Ready for agent execution: yes β€” every [critical]/[important] Open Question has been resolved (see Decisions); the two remaining Open Questions are [nice-to-have] and don't block execution. Start with Phase 0 (query field_properties for every is_default:false + dropdown_select company and run TF-2991 against that list) before any Phase 1 code change, per the sequencing note in Field-property reconciliation.