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:
- The chat/omnichannel producer isn't in this workspace. The
app_name:"chat"payload that duplicatessourceintocustom_fieldsis built inhub-core/hub-service/hub/hub-chat(perdocuments/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. qontak.com's legacy CRM module deliberately depends onsourcestaying human-readable today.Contact360::ParamsMapper#find_or_create_source(qontak.com/app/services/contact360/params_mapper.rb:79-85) andCdpIncomingContactMapper#source_status_attrs(cdp_incoming_contact_mapper.rb:153-167) readcustom_fields["source"]first, fall back to the top-levelsource, and mint/match aCrm::Sourcerow by that string. If contact-service's outbound webhook starts sending a bare UUID instead, this silently creates garbageCrm::Sourcerows named after UUIDs. This is a second cross-team dependency, not an internal implementation detail.- The
sourcefield property itself wasn't always a protected default β dateable, not hypothetical. Between commits9cdc54aand7dfa165(deployed 2026-02-04 to 2026-05-22), every company whose/init/job ran createdsourceas 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:
- 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). Asourcefield property stillIsDefault: falsecan be deleted outright by an admin βDeleteFieldPropertiesonly blocks deletion whenIsDefault: true(contact-service/internal/app/service/field_properties/base.go:429-431). Deleting it makes every existing contact'sSource/SourceIDfor that company permanently unresolvable, with no stored mapping left to recover the name from. - 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. - 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 redundantcustom_fieldsduplicate (effectively all Family-A-created contacts today) is invisible to that filter, with no error surfaced to whoever is segmenting on it. - Downstream CRM data keeps fragmenting.
find_or_create_sourcemints/matches aCrm::Sourcerow 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. - Marketing segments/broadcasts silently misfire today β
segment_service.go:547,671-673propagates whateverSource/SourceNamea 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. - The free part of this fix has a closing window. Running the already-built TF-2991 endpoint
against every currently-
is_default:falsecompany 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:
- 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.
- 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_propertiesdata state (is_default:false+field_type:"dropdown_select"), not by company creation date or the9cdc54aβ7dfa165commit window, since/init/timing isn't tied to signup date (see Field-property reconciliation). Contact.SourceIDdeprecation: removal is optional β downgraded to a[nice-to-have]cleanup item, not a required part of this migration.SourceNamepersistence: persistSourceNamegoing forward (not live-resolve-only) β needed for external consumers (webhook/API responses) that expect a denormalized label. Staleness on dropdown rename is handled by extendingOptionChangeConsumer(item 9) rather than by avoiding persistence.- 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:
- 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
sourceintocustom_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β
| Field | Meaning | Populated by |
|---|---|---|
Contact.Source (bson source) | Stable per-company field_properties["source"].dropdown[].id | Every create/update path, via one shared resolver |
Contact.SourceName | Human-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-read | Every 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)β
| # | Change | Why | Files |
|---|---|---|---|
| 1 | Hoist 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 |
| 2 | Wire 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 |
| 3 | Implement 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) |
| 4 | Stop 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 |
| 5 | Stop 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 |
| 6 | Re-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 |
| 7 | Add 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 |
| 8 | Make "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 |
| 9 | Fix 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 |
| 10 | Audit 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. | β |
| 11 | Re-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_fieldsentry β not before, or it breaks qontak.com's CRM module and any not-yet-migrated read path silently.
qontak-customer-fe (Customer 360 frontend)β
| # | Change | Files |
|---|---|---|
| 1 | Contact 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) |
| 2 | Contact 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 |
| 3 | Source 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 |
| 4 | No 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:
| Window | What /init/ created for source | Commit |
|---|---|---|
| Before 2026-02-04 | Nothing β source wasn't in DefaultFields | β |
| 2026-02-04 β 2026-05-22 | GenerateDefaultFields, but IsDefault: false β plain custom field, deletable | 9cdc54a (TF-2014/TF-2016) |
| After 2026-05-22 | IsDefault: true directly, correct from day one | 7dfa165 (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_selectbucket: no new code β run the existing endpoint against everycompany_sso_idthe query above returns, not a company-creation-date-derived list. Seeding was unconditional, so these already have the correct 20 locked options; flippingis_defaultis 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 checkis_defaultorfield_typeitself before settingis_default:true. If acompany_sso_idis passed in without first confirmingfield_type:"dropdown_select"(e.g. sourced from a creation-date list instead of the query above), the endpoint will silently flipis_default:trueon the rare "incompatible" bucket's unrelated custom text field too. It is safe to over-include already-is_default:truecompanies (idempotent no-op), but never safe to include companies purely because they fall in a date range. - Batching.
MigrateDefaultFieldPropertiesForCompaniesloops overcompany_sso_idssynchronously, 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 hundredcompany_sso_idsper call) rather than one request for the entire population. - "Doc missing" bucket: two populations, same resolution (bootstrap fresh) β companies whose
/init/ran beforesourcejoinedDefaultFields, 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 namedsource. Needs a rename (standardUpdateFieldPropertiesAPI/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
sourcefield 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"].dropdownonce, build an in-memory case-insensitive/trimmed nameβID map (reuse the matching rules inmatchDropdownOption,resolve.go:91-103), then bulk-$setcontacts in batches (e.g. 500) viaUpdateMany/bulk-write. Do not call the per-contact resolver serially. - Scope per company: contacts where
Source != ""andSourcedoesn't already match an existing option'sID. Treat the value as a name and resolve/create against the dropdown, exactly likeResolveOrCreateDropdownOptiondoes for live chat traffic. - State F: for
Source == ""contacts with acustom_fields["source"]entry, promote that value intoSourcebefore resolving β retires the read-time-only fallback inget_contact.go:76-85once done. - State D: audit-only β flag CRM-origin
Sourcevalues 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
sourcedropdown pastMaxDropdownItems(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/v1surface (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β
- Field-property reconciliation first, per company β the backfill resolver is only as correct as the field property it resolves against.
- 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_propertiesfor everycompany_sso_idwheretype:"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
OptionChangeConsumerto propagate dropdown renames into the persistedContact.SourceName(item 9), sinceSourceNameis 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_fieldspreference; re-point the search filter atContact.Source; FE switches filter chips to IDs and list/detail display tosource_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/CdpIncomingContactMapperto consumesource_nameand populatecdp_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_fieldsoverride. - 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_fieldsentries keyed"source"β low priority, skip if not worth the write cost. - Retire
get_contact.go'sCustomFields β Sourcepromotion fallback once backfill is confirmed complete. - Optional:
Contact.SourceIDdeprecation β 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
MaxDropdownItemscap 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
MaxDropdownItemscap in Backfill design is the existing backstop). SourceNamepersistence risk (accepted): persisting reintroduces a staleness class of bug on dropdown rename. Mitigated by extendingOptionChangeConsumerto propagate renames into the systemContact.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_fieldsremoval 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.SourceID pointing at them.GetDropdownOptionNamealready 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:
- [nice-to-have]
Contact.SourceIDfate: 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. - [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
## Storiestable (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/QAandcapacity.mdAllocation. - 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.