Skip to main content

[PRD] Qontak CDP | Customer Health Score — Phase 1: Rule-Based Scoring Engine (MVP)

Evolves: [PRD] Customer Signal Label (page 51135449719) — the label-only Hot/Warm/Cold discovery. That doc explicitly deferred numeric 0–100 scoring as "too complex for MVP"; this PRD delivers the numeric score with the label derived from configurable thresholds, so the health score subsumes the signal label. Grounded: validated against contact-service (CDP), qontak.com (CRM), qontak-customer-fe, and the chat stack — see Appendix A. Benchmarked against Zoho Zia, HubSpot, Salesforce, and Gainsight — see Appendix B. Aligned + repo-grounded (v1.4): metrics aligned across Customer Health Score · Customer Segmentation · CEBE — Field Attributes reuse the shared segment_engine operator logic per field type; use-case signals (Engagement/Conversion/Support/CSAT) come from their real systems of record — CRM (qontak.com) + omnichannel (hub-chat), some already in CDP — ingested via a CDP consumer or the CEBE metrics layer (CEBE is a path, not the source).

HEADER BLOCK

FieldValue
PMZhelia Alifa
PRD Version1.7
StatusDRAFT
PRD TypeNEW
EpicTF-3498
SquadCDP Squad
RFC LinkN/A — RFC to follow this PRD
Figma MasterTBD — score config builder + score display
AnchorNo — standalone CDP feature
Labelsepic:qontak-cdp | module:customers | feature:customer-health-score
Last Updated2026-07-08

Table of Contents

1. One-liner + Problem

One-liner: A configurable 0–100 Customer Health Score for CDP contacts, so Sales/Marketing/CS prioritise by one explainable number instead of gut feel.

Problem: CDP today is a data viewer, not an intelligence layer. A contact's profile, conversations, deals, tickets, and CSAT all exist in Qontak, but there is no single measure of how healthy / high-intent / at-risk a customer is. As a result:

  • Sales follow-up is inconsistent and driven by intuition (which contact to call first?).
  • Marketing campaign targeting is broad and inefficient (no way to segment by health).
  • CS misses at-risk customers until churn is visible in lost deals or complaints.
  • There is no numeric foundation for downstream automation, re-engagement triggers, or next-best-action.

The earlier Customer Signal Label effort validated the need but shipped only a coarse Hot/Warm/Cold label. Clients asked for a transparent, tunable score they can configure to their own definition of "healthy" — not a fixed 3-bucket label and not a black-box ML score.

2. What Happens If We Don't Build This

  • CDP stays a passive data viewer — the "intelligence layer" positioning in the CDP roadmap does not materialise, capping CDP adoption and stickiness (the initiative's north-star metric).
  • Teams keep prioritising customers by manual judgement, so follow-up stays inconsistent and high-intent customers keep slipping through.
  • No numeric substrate exists for the next CDP bets — segmentation by health, health-triggered broadcasts, re-engagement automation, next-best-action — each of which assumes a score.
  • Competitors (Zoho Zia, HubSpot, Salesforce, Gainsight — Appendix B) already ship customer/health scoring; Qontak CDP remains behind on a table-stakes CRM-intelligence capability.

3. Target Users + Persona Context

PersonaRoleGoalPain (today)Workaround
Primary — CDP Admin / OpsConfigures CDP for the companyEncode the company's definition of a "healthy" customer once, so the whole team works from one consistent scoreNo scoring exists; can only build static segments with no notion of degree/priorityBuild many manual segments + written playbooks that quickly go stale
Secondary — Score consumer (Sales · Marketing · CS)Works contacts day-to-day — Sales prioritises follow-up, Marketing targets campaigns, CS retains accountsSee at a glance which contacts to act on first (call, target, or save)No ranking — Sales scans raw fields/deals per contact, Marketing has no health signal to segment on, CS only sees at-risk after churn/complaintPersonal spreadsheets + gut feel; broad low-reply blasts; reactive manual account reviews

4. Non-Goals

  1. No machine-learning / predictive model in MVP. The score is a transparent rule-based computation the admin fully configures — not an AI-trained predictor like Zoho Zia (which requires training data). AI/ML scoring is a possible later phase.
  2. No real-time (per-event) recalculation — the score is recomputed on a daily schedule. Sub-daily/real-time recompute is out of scope for MVP.
  3. No more than one active health-score configuration per client in MVP (a client cannot run several parallel scoring schemes).
  4. NPS is out of MVP scope — satisfaction is CSAT-only. Per-customer CSAT is the sole satisfaction signal for MVP (grounded: CRM csats per crm_person). Per-customer NPS is not modelled in MVP — the CRM only has per-agent NPS today (users.last_nps_submission) — and can be revisited in a later phase (see OQ-1 / D-5).
  5. No per-metric custom formula language — clients pick from the curated metric catalog (§6.2) and set point values / thresholds; they cannot write arbitrary expressions.
  6. No historical back-scoring / score history timeline in MVP — only the current score + last-computed timestamp are stored (trend history is a later phase).
  7. No automation/trigger actions off the score (broadcast, re-engagement, next-best-action) in MVP — the score is produced and displayed; acting on it is downstream scope.
  8. Score is not a per-agent or per-company-account rollup — it is scoped to a single CDP contact.

Scope Changes

Engineering surfaces this PRD touches (controlled vocab). Kept in sync with the scope_changes frontmatter above.

  • Backendcontact-service: net-new health_score (int) + health_score_components + health_score_label + health_score_computed_at on the contact doc; a company-scoped health_score_config collection (components on/off, per-metric rules, thresholds, labels — modelled on the existing CustomerSegment config); a daily recompute job reusing the gocraft/work cron pattern; a per-contact signal-aggregation step (counts/sums/averages) reusing the segment_engine operator layer.
  • Backendqontak.com: expose per-contact aggregate signals the CDP score needs that are not fully derivable from CDP activity logs today — won-deal count + deal-size sum + lost-deal count, ticket count/open/priority, CSAT average, call count (see §7 + D-4).
  • Frontendqontak-customer-fe: score-config admin UI (component on/off, metric picker ≤3/component, thresholds, labels), Customer Index score column + label filter, Customer Detail score breakdown (per-component contribution + contributing rules).
  • Data — daily per-contact aggregation of conversation / WhatsApp-call / campaign / deal / ticket / CSAT signals into the score.
  • Design — Figma for the config builder and the score display (currently TBD).

5. Constraints

ConstraintValue
Feature flagcdp_customer_health_score_enabled | default: OFF — enabled per company by Ops.
Plan / tierCDP-enabled plans — Growth and Enterprise (same tier as other CDP intelligence features). Not Starter.
PlatformConfig + display on web (qontak-customer-fe). Score is computed server-side in contact-service; mobile read-only display is out of scope for MVP (score field will be returned by the contact API, so mobile can render later with no backend change).
Score range & structureFinal score is an integer 0–100. Default template = 5 components, each capped at 20. A client may turn components on/off; when N components are active, each active component's cap auto-redistributes to round(100 / N) (remainder → first component; rounding rule is OQ-4). The client may also manually edit the per-component caps, as long as active caps still total exactly 100.
Metrics per componentMaximum 3 active metrics per component (MVP complexity guard). Metrics are chosen from the curated catalog (§6.2); each metric is a rule condition → points (points may be positive or negative).
Component scorecomponent_score = clamp( Σ points of matched rules, 0, component_cap ) — a component can never go below 0 or above its cap.
Final scorehealth_score = Σ component_score over active components (already ≤ Σ caps = 100).
Labels & thresholdsClient defines threshold bands → label. Qontak ships defaults: 75–100 = Healthy · 50–74 = Neutral · 0–49 = At-risk. Bands and label text are fully editable (label names, colours); bands must be contiguous and cover 0–100 with no gaps/overlaps.
Active configsExactly one active health_score_config per company in MVP (additional configs may be saved as draft/archived but only one is active).
Recompute cadenceDaily batch recompute (scheduler), plus a manual "recompute now" trigger for admins. Not real-time.
PerformanceDaily recompute ≤ 4 hours for the largest tenant; batched contact updates (bulk write). Customer Index must render the stored score with no added latency (score is a stored field, not computed at read time). Config save ≤ 2s.
Data availability (repo-grounded)Field Attributes → shared segment_engine over Postgres customer_datamart (operators per field type — §6.2), reachable now (default + custom). Use-case signals come from their systems of record: conversation recency/frequency + campaign delivered/read are reachable in CDP now (activity_log); deals / tickets / CSAT / WA-calls live in the CRM (qontak.com) per crm_person; campaign reply/rate/type + WA-call activity live in the omnichannel backend (hub-chat); orders via external_activity_log (now, if a Loyalty/POS integration posts them). The CRM + omnichannel signals are not ingested into CDP today → delivered via a new/extended CDP consumer or the CEBE metrics layer (§13; D-10). NPS is out of MVP scope (OQ-1). Store split: attributes + the label filter live in customer_datamart; the contact doc is MongoDB — store-of-record is OQ-8.
ExplainabilityThe score must be explainable — the Customer Detail view shows each component's contribution and which rules matched. No black-box output.

5.1 Data Lifecycle

ArtifactRetentionVisibility
health_score + component breakdown + label on the contactLive — overwritten each daily recompute (no history kept in MVP; trend history is a later phase, OQ-7)Same visibility as the contact (respects contact permission / team-owner scope)
health_score_config (active)Persistent until edited/archivedCDP Admin only
health_score_config (draft / archived versions)Retained for audit/rollback of a scoring schemeCDP Admin only
Recompute job run log (per company)90 daysInternal / CDP Squad
Per-contact signal aggregates (intermediate)Transient within a job run; not persisted beyond the computed component scoresInternal

6. New Features

Three net-new CDP surfaces + one backend engine, plus a read-only score display on the Inbox customer-profile panel — web inbox (hub-chat) and chat mobile app (cross-team surfaces; see CHS-S08 & §13). No net-new contact fields are user-editable — the score is system-computed. The score renders inside the customer-profile section, so its visibility is inherited from that section's view permission — a user without profile-view permission sees the section hidden and the score with it (CHS-S08; no separate health-score permission). Once computed, the score is also consumed across CDP: as a numeric segment condition (CHS-S09), on the MAG public API — get-all + single customer (CHS-S10), as a filter in the direct Select Contact menu (CHS-S11), and via the S2S customer-details API for other services (CHS-S12).

Component tree:

Customer Health Score (CDP)
├── Health Score Config (Admin) — company-scoped, 1 active
│ ├── Component toggles (on/off; 5 default) + auto cap redistribution (client-editable caps)
│ ├── Per-component metric picker (≤3 metrics from the curated catalog §6.2)
│ │ └── each metric = condition (operator + value) → points (+/-)
│ ├── Threshold bands → labels (default Healthy/Neutral/At-risk; editable)
│ └── Save / Activate / "Recompute now"
├── Scoring Engine (backend, contact-service)
│ ├── SignalAggregator — per-contact daily aggregates (fields via segment_engine; deals/tickets/CSAT/calls from CRM; campaign/calls from omnichannel; some via CDP consumers today)
│ ├── ScoreEvaluator — applies config rules (reuses segment_engine operators) → component scores → total → label
│ └── DailyRecomputeJob — gocraft/work cron; bulk-writes health_score onto contacts
├── Customer Index — new "Health Score" column (score + label chip) + filter by label
└── Customer Detail — score + label + breakdown (per-component contribution + which rules matched)

UI states (all three surfaces):

SurfaceLoadingEmptyErrorSuccess
Config builderSkeleton formNo config yet → "Start from default template" CTAInline validation (bands overlap/gap; >3 metrics; caps ≠ 100) blocks saveConfig saved + "active"; "Recompute now" enabled
Customer Index columnColumn skeletonScore not yet computed → "—" (neutral placeholder)Column hidden, list still renders (fail-silent)Score + coloured label chip; filter by label works
Customer Detail breakdownPanel skeleton"Health score not yet computed for this contact""Couldn't load score breakdown" (contact still renders)Score, label, 5 component bars, matched-rule explanation

📊 UI State — Config Builder & Score Display

stateDiagram-v2
[*] --> NoConfig
NoConfig --> Editing: "Start from default template"
Editing --> ValidationError: save fails (caps≠100 / >3 metrics / band gap-overlap)
ValidationError --> Editing: fix highlighted field
Editing --> ActiveConfig: save + activate (valid)
ActiveConfig --> Editing: edit config
ActiveConfig --> Recomputing: daily schedule OR "Recompute now"
Recomputing --> Scored: bulk-write score + label + computed_at
Recomputing --> HeldOnShift: >40% label-band shift (distribution guard §9.2)
HeldOnShift --> ActiveConfig: admin confirms OR revert to prior version
Scored --> Recomputing: next run / config change
Scored --> [*]
note right of Scored
Contact surfaces: Index column + label chip,
Detail breakdown, + Inbox customer-profile panel (web + chat mobile). Not-yet-scored shows "—".
end note

6.1 Scoring Model

Definitions

  • A component is one of the five signal groups. The default template enables all five; a client can disable any (min 1 active). Active components split 100 points evenly by default: cap = round(100 / active_count) (remainder → first component; OQ-4) — but the client may edit the per-component caps manually, as long as active caps still total exactly 100.
  • A metric is one rule inside a component: condition (field/operator/value) → points. Points can be positive (adds health) or negative (subtracts). Max 3 metrics per component.
  • Component score = clamp( Σ matched-rule points, 0, cap ).
  • Health score = Σ component_score across active components → integer 0–100.
  • Label = the band the health score falls into (default 75–100 Healthy / 50–74 Neutral / 0–49 At-risk; editable).

Worked example (default template, all 5 components, cap 20 each):

ComponentActive metrics (example)MatchedRawCapped
Field Attributesphone/email/username is_not_empty (+10); Job title contains "Manager/Director" (+5); Source ∈ {Referral, Website} (+5)all three+2020
EngagementLast convo ≤7d (+8); ≥3 convos/30d (+7); campaign reply rate ≥20% (+5)all three+2020
ConversionWon deals ≥1 (+10); won deal rate ≥50% (+5); recent won ≤90d (+5)all three+2020
SupportNo open ticket (+10); issues resolved ≥80% (+10)both+2020
Satisfaction (CSAT)Avg CSAT ≥4/5 (+12); recent CSAT ≤90d (+8)both+2020
Total100 → Healthy

Recompute & change semantics: editing the active config or clicking "Recompute now" re-scores all contacts on the next job run; the score reflects the config live at compute time. When components are toggled, caps auto-redistribute to total 100 (and the client may edit caps manually) — a contact's absolute score can therefore shift after a config change (this is expected; flagged in Observability + Rollback, §9/§10).

6.2 Metric Catalog (curated — repo-grounded)

Qontak ships a default template (the ★ metrics below are pre-selected). Clients swap in other catalog metrics (max 3 active per component) and set the point values. Each metric is grounded in its real system of record — the operator engine for attributes, and the CRM / omnichannel stack for the use-case signals — not a single abstract source.

🔗 Alignment + source model (read first). The Health Score composes what the CDP already has and reuses the same substrates as Customer Segmentation + CEBE, but the data comes from the real systems of record (grounded in Appendix A):

  • Field Attributes (Component 1) → the segment_engine operator logic per field type, for default and custom fields — the same operator set the Customer Segmentation — Basic Attributes PRD defines. One operator engine, reused (D-11).
  • Use-case components read per-customer signals from their systems of record: CRM (qontak.com) for deals / tickets / CSAT / calls (per crm_personperson.rb, csat.rb, call_recordings), and the omnichannel / WhatsApp backend behind hub-chat for campaign delivery/reply/type + WA-call activity. Some are already ingested in CDP today (conversation room events, campaign delivered/read, orders via external_activity_log); the rest need an ingestion path (a CDP consumer or the CEBE metrics layer, §13). CEBE is an ingestion path, not the source — the source is the CRM / omnichannel data above (D-10).

Source legend (grounded — Appendix A):CDP now (already in contact-service) · 🗄️ CRM qontak.com (per crm_person) · 💬 omnichannel / hub-chat (WA / campaign backend) · ⚙️ shared segment_engine · ⏳ needs ingestion (exists at source, not yet in CDP — via a CDP consumer or CEBE, §13).

Component 1 — Field Attributes (default & custom fields) (static profile / firmographic fit — evaluated by the shared segment_engine operators, per field type; ⚙️ ✅ reachable now over Postgres customer_datamart, segment.go:34–52). The client picks a field, and the operators available are driven by that field's type — the same reusable set as Customer Segmentation (default & custom alike) — then sets the example rule → points:

Field typeOperators (reusable — Segmentation ⇄ Health Score)Example rule → points
Text (single_line_text / multi_line_text / url)is · is_not · contains · does_not_contain · starts_with · ends_with · is_empty · is_not_emptyJob title contains "Manager/Director/C-" → +5; email is_not_empty+3 (profile completeness)
Dropdown / multi_selectis · is_not · contains · does_not_contain · contains_any · contains_all · is_empty · is_not_empty★ Customer Type is "VIP" → +10; ★ Lifecycle Status is "Active Customer" → +5; Source contains_any {Referral, Website} → +3
Number (+ currency / income tier)equals · not_equals · greater_than · less_than · between · is_empty · is_not_emptyAvg annual income greater_than X → +5; company size between a…b → +N
Dateon · not_on · before · after · between · is_empty · is_not_emptyCustomer-since before [date] → +5 (tenure); last profile update after [date] → +N
Booleanis · is_notIs-loyalty-member is true → +5; marketing opt-in is true → +3
File / signature / GPSis_empty · is_not_empty onlyID / KTP document is_not_empty+3 (verified profile)

★ default-template presets: Customer Type, Lifecycle Status, and a profile-completeness rule.

Component 2 — Engagement (interaction recency, frequency & campaign responsiveness — every metric carries a time window)

MetricOperator (+ window)Example rule → pointsSource
★ Recent conversationlast-activity ≤ N dayslast conversation ≤ 7d → +8✅ CDP now (activity_log room, room_consumer.go) · 💬 hub-chat rooms (last_message_at)
★ Conversation frequencycount ≥ N in window≥ 3 conversations in 30d → +7✅ CDP now (activity_log room events) · 💬 hub-chat rooms per qontak_customer_id
★ Campaign reply rateratio ≥ % in windowreplies / delivered ≥ 20% in 90d → +5💬 hub-chat (message_status_count + inbound is_campaign) ⏳
Campaign delivered rateratio ≥ % in windowdelivered / sent ≥ 90% in 90d → +N💬 hub-chat (message_status_count) · ✅ delivered/read in CDP now ⏳
Number of campaign repliescount ≥ N in window≥ 2 campaign replies in 90d → +N💬 hub-chat (inbound is_campaign) ⏳
Last campaign deliveredlast-delivered ≤ N daysdelivered ≤ 30d → +N✅ CDP now (activity_log campaign delivered/read)
Last campaign replylast-reply ≤ N daysreply ≤ 30d → +N💬 hub-chat (inbound is_campaign) ⏳
Campaign reply typetype is {broadcast / utility}reply to a utility template → +N (vs broadcast)💬 hub-chat (message_template.category MARKETING / UTILITY / AUTH) ⏳
WhatsApp call — countcount ≥ N in window≥ 1 call in 90d → +N🗄️ CRM call_recordings (crm_person_id) · 💬 hub-chat WA-call ⏳
WhatsApp call — recencylast-call ≤ N dayslast call ≤ 30d → +N🗄️ CRM call_recordings.call_time
WhatsApp call — durationavg / sum ≥ secondsavg call duration ≥ 60s in 90d → +N🗄️ CRM call_recordings.duration · 💬 hub-chat callDuration
WhatsApp call — directiondirection is {inbound / outbound}≥ 1 inbound (customer-initiated) call in 90d → +N🗄️ CRM call_type · 💬 hub-chat direction (USER / BUSINESS_INITIATED) ⏳

(Inactivity penalty removed — the recency metrics already capture disengagement.)

Component 3 — Conversion (revenue intent — CRM deals per crm_person + commerce orders via external_activity_log)

MetricOperator (+ window)Example rule → pointsSource
★ Won dealscount ≥ Nwon deals ≥ 1 → +10🗄️ CRM (crm_deals × crm_stages.win_probability = 1, person.rb:59–63) ⏳
★ Won deal rateratio ≥ %won ÷ all associated deals ≥ 50% → +5🗄️ CRM (won count ÷ crm_people_deals total) ⏳
★ Total spend (LTV) / won valuesum ≥ amountΣ won deal size ≥ Rp50m → +5🗄️ CRM (crm_deals.size, compute_deal_aggregates_usd, person.rb:742–791) ⏳
Recent won deallast-won ≤ N dayswon ≤ 90d → +5🗄️ CRM (crm_deals won updated_at) ⏳
Commerce — total purchases (order count)count ≥ N in window≥ 2 orders in 180d → +N✅ CDP now if Loyalty/POS integrated (external_activity_log order_confirmed / payment_confirmed)
Commerce — total purchase amountsum ≥ amountΣ order amount ≥ X → +Nexternal_activity_log Attributes.amount (if integrated)
Commerce — last purchase (recency)last-purchase ≤ N daysorder / payment ≤ 30d → +Nexternal_activity_log order / payment timestamp
Commerce — avg order valueavg ≥ amounttotal amount ÷ orders ≥ X → +Nexternal_activity_log (amount ÷ order count)

(Lost-deal penalty removed per PM feedback. Commerce metrics require a Loyalty/POS integration posting to external_activity_log; without it, deal value proxies spend — OQ-2.)

Component 4 — Support Interaction (support friction from the customer's point of view — CRM tickets per crm_person; a healthy customer has few / no unresolved issues)

MetricOperator (+ window)Example rule → pointsSource
★ No open tickethas-open is falseno currently-open ticket → +10🗄️ CRM (tickets × ticket_stages.win_probability < 1, people_tickets) ⏳
★ Recent complaintlast-ticket ≤ N daysticket opened ≤ 7d → −5🗄️ CRM (tickets.created_at recency) ⏳
★ Issues get resolvedresolved-ratio ≥ %resolved ÷ total ≥ 80% → +N (the customer's issues do get closed)🗄️ CRM (win_probability = 1 ÷ total) ⏳
Ticket volumecount > N in window> 5 tickets in 90d → −3 (recurring friction)🗄️ CRM (people_tickets count) ⏳

Component 5 — Satisfaction (CSAT) (CSAT only for MVP — per-customer csats; NPS is out of MVP scope, see §4 / OQ-1)

MetricOperator (+ window)Example rule → pointsSource
★ High average CSATavg ≥ thresholdavg CSAT ≥ 4/5 → +12🗄️ CRM (csats.option_value avg per crm_person, csat.rb) ⏳
★ Low average CSATavg ≤ thresholdavg CSAT ≤ 2/5 → −8🗄️ CRM (csats avg) ⏳
★ Recent CSAT responselast-response ≤ N daysCSAT response ≤ 90d → +8🗄️ CRM (csats.selected_at) ⏳
CSAT participationcount ≥ N in window≥ 1 CSAT submitted in 180d → +N🗄️ CRM (csats count where selected) ⏳

★ = default-template preset. Source legend:CDP now · 🗄️ CRM qontak.com (per crm_person) · 💬 omnichannel / hub-chat · ⚙️ shared segment_engine · ⏳ needs ingestion into CDP (via a CDP consumer or the CEBE metrics layer — §13). Systems of record are grounded in Appendix A; CEBE is one standardized ingestion path, not the data source (D-10).

7. API & Webhook Behavior

Server-side enforcement/computation lives in contact-service. Written in plain language; HTTP/JSON contracts are for the RFC.

#BehaviorEntity AffectedTriggered ByExpected BehaviorFailure Behavior
1Save health-score confighealth_score_config (company-scoped)Admin saves in the config builderValidate: components ≥1 active, caps total 100, ≤3 metrics/component, bands contiguous & cover 0–100, each metric references a catalog metric available for the company's data. Persist; mark one active.Validation fail → 422 with the offending rule highlighted; previous active config unchanged.
2Activate confighealth_score_configAdmin clicks ActivateSet chosen config active, demote the prior active to archived (only one active per company).Conflict (another activate in-flight) → 409; retry.
3Daily recompute (all contacts)contact.health_score + breakdown + labelScheduler (daily) per company with flag ONFor each contact: aggregate signals (behaviour #5), apply active config, compute component scores → total → label; bulk-write onto contacts; stamp health_score_computed_at. Emit run summary.Aggregation source (CRM signals) unavailable → skip that signal, compute with available components, log health_score_partial_source; job continues. Whole-job failure → alert + last good scores retained.
4Recompute now (manual)same as #3Admin clicks "Recompute now"Enqueue an immediate recompute for the company (same path as daily). Rate-limited (e.g. ≤1 / 10 min).Rate-limited → inline "recompute already running / try later".
5Aggregate per-contact signalsin-memory (per contact)recompute jobAssemble the values the active metrics need from their systems of record: Field attributes via segment_engine over customer_datamart (CDP, now); conversation recency/frequency + campaign delivered/read via activity_log (CDP, now); deals won/lost/rate/value (CRM crm_deals × crm_people_deals × crm_stages), tickets open/priority/recency (CRM people_tickets × ticket_stages/ticket_priority), CSAT avg/recency (CRM csats), WA calls count/recency/duration (CRM call_recordings), campaign reply/rate/type (omnichannel/hub-chat) — the CRM + omnichannel signals arrive via a CDP consumer or the CEBE metrics layer (§13). No NPS in MVP.A source (or a not-yet-ingested signal) is unavailable → that component's affected metrics score 0 for that contact; log health_score_signal_missing with source; do not fail the contact.
6Read score on contactcontact read APIsCustomer Index / Detail loadContact list + detail responses include health_score, health_score_label, and (detail only) health_score_components breakdown. Index filter by label maps to a stored-field query.Score not yet computed → fields null → UI shows "—" / "not yet computed" (behaviour, not error).
7Score changed event (optional, for downstream)contact.health_scorerecompute writes a materially changed score/labelEmit a cdp_health_score_changed event (contact id, old/new label, score) for future automation consumers. MVP: emitted but no consumer acts on it (Non-Goal 7).Event bus down → dropped; not retried in MVP (score is still persisted on the contact).
8Expose score on the MAG public APIcontact (public list + single)Consumer calls MAG Get all customers / Get single customerInclude health_score, health_score_label, health_score_computed_at in the customer payload (read passthrough from the stored fields). Additive/backward-compatible; feature-gated per company.Flag OFF or not yet computed → fields null; never an error (CHS-S10).
9Expose score on the S2S customer-details APIcontact (internal S2S)Another service calls S2S Get customer detailsInclude the same three health_score* fields (read passthrough) so other services read the score without re-deriving it.Flag OFF or not computed → null (CHS-S12).
10Filter / segment by scorecontact query (Index filter · Direct Select Contact menu · Segment rule)User filters the Index / picks recipients in the Select Contact menu / builds a segmentHealth-score numeric condition (0–100) evaluated by segment_engine using the Number operator set (equals/not_equals/greater_than/less_than/between/is_empty/is_not_empty) — or the derived label as a dropdown — mapped to a stored-field query over the score store-of-record (OQ-8).Flag OFF → the field/filter is not offered; is_empty matches not-yet-scored (CHS-S09/S11).

8. System Flow + User Stories + ACs

8.1 System Flow

  1. CDP Admin opens Settings → Customer Health Score (flag cdp_customer_health_score_enabled ON, Growth/Enterprise).
  2. If no config exists, admin starts from the default template (5 components, ★ preset metrics, default bands).
  3. Admin toggles components on/off (min 1). System redistributes caps so active caps total 100.
  4. Per active component, admin picks up to 3 metrics from the catalog (§6.2) and sets each rule's points; the catalog disables metrics whose source signal is not yet ingested into CDP for the company (e.g. deals/tickets/CSAT/WA-calls before their CRM/omnichannel ingestion lands), labelling each with its source; metrics whose signal is live (attributes, conversation, campaign delivered/read) are selectable. NPS is not offered (out of MVP).
  5. Admin edits threshold bands + labels (default Healthy/Neutral/At-risk); system validates bands are contiguous and cover 0–100.
  6. Admin saves + activates (exactly one active config; prior active → archived).
  7. Daily scheduler (and manual "Recompute now") runs the recompute job for the company.
  8. For each contact, SignalAggregator assembles field values (via segment_engine) + use-case signals from their systems of record (CRM deals/tickets/CSAT/calls; omnichannel campaign; CDP activity_log for conversation + campaign-delivered) — ingested via a CDP consumer or CEBE.
  9. ScoreEvaluator applies the active config: matched rules → component raw → clamp(0, cap) → sum → health_score; maps to a label via bands.
  10. Job bulk-writes health_score, health_score_components, health_score_label, health_score_computed_at onto each contact; emits a run summary.
  11. Sales/Marketing/CS open Customer Index → see the score column + label chip, filter by label.
  12. They open Customer Detail → see the score, label, and per-component breakdown (which rules matched).
  13. Failure branch: if a signal source (e.g. CRM deal aggregate) is unavailable, the affected metrics score 0 for that contact, health_score_signal_missing is logged, and the rest of the score still computes (no contact is skipped; last good score retained on total job failure).

📊 System Flow — Customer Health Score

sequenceDiagram
participant Admin as CDP Admin
participant Cfg as Health Score Config (contact-service)
participant Sched as Daily Scheduler (gocraft/work)
participant Agg as SignalAggregator
participant Src as Sources (segment_engine fields · CRM deals/tickets/CSAT/calls · omnichannel campaign · CDP activity_log)
participant Eval as ScoreEvaluator
participant C as Contact docs
participant User as Sales / Marketing / CS
Admin->>Cfg: save config (components on/off, metrics ≤3, bands, labels)
Cfg-->>Admin: validate (caps=100, bands cover 0-100) → active
Sched->>Agg: run recompute (company, flag ON)
loop each contact
Agg->>Src: fetch field values + CRM/omnichannel signals (via consumer/CEBE)
alt source unavailable
Src-->>Agg: timeout / signal not yet ingested
Agg->>Agg: affected metrics = 0; log health_score_signal_missing
else ok
Src-->>Agg: counts / sums / averages
end
Agg->>Eval: aggregates + active config
Eval->>Eval: rules → component clamp(0,cap) → sum → label
Eval->>C: bulk-write score + breakdown + label + computed_at
end
User->>C: open Customer Index / Detail
C-->>User: score + label chip + breakdown

8.2 User Stories

User StoryImportanceMockupTechnical NotesAcceptance Criteria
[CHS-S01] — Configure components & metrics

As a CDP Admin, I want to enable/disable score components and pick the metrics inside each, so that the health score reflects my company's definition of a healthy customer.
Must HaveFigma: TBD — config builderData Fields:
components[] (each: key, enabled, cap) — 5 defaults
metrics[] per component (≤3; each: catalog_metric_id, operator, value, points)
• operators reuse segment_engine (is/greater_than/between/before/last-N-days)

Before-After Behavior: Before: no scoring config exists (only static segments). After: a company-scoped health_score_config (modelled on CustomerSegment) stores component toggles + per-metric rules.
— Happy Path —
• AC-1: Given the flag is ON and no config exists, when the admin opens the builder, then the default template loads (5 components enabled, ★ preset metrics, cap 20 each).
• AC-2: Given the builder, when the admin disables a component, then remaining active caps redistribute so active caps total exactly 100.
• AC-3: Given a component, when the admin adds metrics, then no more than 3 metrics can be active for that component (the add control disables at 3).
• AC-4: Given a metric, when the admin sets a condition + points, then positive and negative point values are both accepted.
• AC-5: Given a metric whose source signal is not yet ingested into CDP for the company (e.g. deals/tickets/CSAT/WA-calls before their CRM/omnichannel ingestion lands), when the catalog is shown, then that metric is disabled and labelled with its source/availability (e.g. "available when CRM deal ingestion ships"); metrics whose signal is live (attributes, conversation, campaign delivered) are selectable.

— Error / Unhappy Path —
• ERR-1: Given a save, when active caps do not total 100 or a component has >3 metrics, then save is blocked with an inline message; nothing persists.
• ERR-2: Given all components disabled, when the admin tries to save, then save is blocked ("at least one component required").

— Permission Model —
• CAN: CDP Admin / Owner.
• CANNOT: Sales/Marketing/CS agents (read-only consumers of the score).
• Unauthorized: Settings entry not rendered; direct URL → 403.

— UI States —
• Loading: form skeleton.
• Empty: "Start from default template" CTA.
• Error: inline validation on the offending field.
• Success: config saved (draft) toast.
[CHS-S02] — Configure threshold bands & custom labels

As a CDP Admin, I want to define score bands and their labels, so that the numeric score maps to language my team uses.
Must HaveFigma: TBD — bands editorData Fields:
bands[] (each: min, max, label, colour)

Before-After Behavior: Before: none. After: bands map score→label; Qontak ships defaults 75–100 Healthy / 50–74 Neutral / 0–49 At-risk, all editable.
— Happy Path —
• AC-1: Given the bands editor, when it loads, then the three default bands (Healthy/Neutral/At-risk) are pre-filled and editable (label text + colour + min/max).
• AC-2: Given custom bands, when the admin saves, then the bands must be contiguous and cover 0–100 with no gaps or overlaps, else save is blocked.
• AC-3: Given a saved band set, when a contact scores within a band, then that contact's health_score_label = that band's label.

— Error / Unhappy Path —
• ERR-1: Given overlapping (e.g. 40–60 and 55–80) or gapped bands, when the admin saves, then save is blocked with the conflict highlighted.

— Permission Model —
• CAN: CDP Admin / Owner. • CANNOT: agents.

— UI States —
• Loading: skeleton. • Empty: defaults shown. • Error: overlap/gap highlight. • Success: bands saved.
[CHS-S03] — Daily automatic scoring of every contact

As a CDP user, I want the system to automatically compute each contact's health score daily, so that priorities stay current without manual work.
Must HaveFigma: N/A — backend jobData Fields:
• writes health_score (int 0–100), health_score_components (per-component sub-scores), health_score_label, health_score_computed_at
• reuses gocraft/work cron (worker_service.go PeriodicallyEnqueue)

Before-After Behavior: Before: no score exists on contacts. After: a daily job aggregates signals, applies the active config, and bulk-writes a score+label onto every contact.
— Happy Path —
• AC-1: Given an active config and flag ON, when the daily job runs, then every contact for the company gets health_score = Σ clamped component scores (0–100) and a health_score_label from the bands.
• AC-2: Given a component's matched-rule points exceed its cap, when scored, then that component score is clamped to its cap (never exceeds cap, never below 0).
• AC-3: Given the job completes, when it finishes, then health_score_computed_at is stamped and a run summary (contacts scored, label distribution, duration) is emitted.
• AC-4: Given the largest tenant, when the job runs, then it completes within the ≤4h performance budget (bulk writes).

— Error / Unhappy Path —
• ERR-1: Given a signal source (e.g. CRM deal aggregate) is unavailable for a contact, when scoring, then the affected metrics score 0, health_score_signal_missing is logged with the source, and the contact is still scored on remaining components (not skipped).
• ERR-2: Given the whole job fails, when it aborts, then contacts retain their last good score (no partial wipe) and an alert fires.

— Permission Model —
• CAN: system (scheduler) + Admin ("Recompute now").
• CANNOT: agents cannot trigger recompute.

— UI States — N/A (backend). Admin sees last-run status + timestamp in the config page.
[CHS-S04] — See health score on Customer Index

As a Sales / Marketing / CS user, I want to see each contact's health score and label in the Customer Index and filter by label, so that I can prioritise at scale.
Must HaveFigma: TBD — index column + filterData Fields:
• reads stored health_score, health_score_label (no compute at read time)

Before-After Behavior: Before: index shows raw fields only. After: a "Health Score" column (score + coloured label chip) + a label filter.
— Happy Path —
• AC-1: Given computed scores, when the user opens the Customer Index, then a Health Score column shows the score + label chip per contact, sorted/rendered from the stored field (no read-time latency).
• AC-2: Given the label filter, when the user filters by "At-risk", then only contacts with that label are listed.
• AC-3: Given a contact not yet scored, when the index renders, then its cell shows "—" (not an error).

— Error / Unhappy Path —
• ERR-1: Given the score field fails to load, when the index renders, then the column is hidden and the list still renders (fail-silent).

— Permission Model —
• CAN: any CDP user who can view contacts (respects team-owner / permission scope).
• CANNOT: users without contact-view permission.

— UI States —
• Loading: column skeleton. • Empty: "—". • Error: column hidden. • Success: score + chip + filter.
[CHS-S05] — Understand why (score breakdown on Customer Detail)

As a CS / Sales user, I want to see how a contact's score was built, so that I trust it and know what to act on.
Must HaveFigma: TBD — detail breakdownData Fields:
• reads health_score_components (per-component sub-score + matched rules) + label

Before-After Behavior: Before: none. After: Customer Detail shows the total score, label, five component contributions, and which rules matched.
— Happy Path —
• AC-1: Given a scored contact, when the user opens Customer Detail, then the total score + label + each active component's contribution (e.g. "Engagement 20/20") are shown.
• AC-2: Given the breakdown, when expanded, then the matched rules are listed in plain language (e.g. "Last conversation ≤7 days: +8").
• AC-3: Given a disabled component, when the breakdown renders, then it is not shown (only active components appear).

— Error / Unhappy Path —
• ERR-1: Given the breakdown fails to load, when the detail renders, then a "couldn't load score breakdown" note shows and the rest of the contact still renders.
• ERR-2: Given a not-yet-scored contact, when detail opens, then "Health score not yet computed" is shown.

— Permission Model —
• CAN: any user who can view the contact. • CANNOT: others.

— UI States —
• Loading: panel skeleton. • Empty: "not yet computed". • Error: inline note. • Success: full breakdown.
[CHS-S06] — Recompute after a config change

As a CDP Admin, I want changing the config (or clicking "Recompute now") to re-score contacts, so that a tuning change takes effect without waiting a day.
Should HaveFigma: TBD — recompute actionData Fields:
• "Recompute now" trigger (rate-limited ≤1/10min)
• uses the config live at compute time

Before-After Behavior: Before: scores would only change on the next daily run. After: an activate/"Recompute now" enqueues an immediate recompute for the company.
— Happy Path —
• AC-1: Given an activated config change, when the admin clicks "Recompute now", then an immediate recompute enqueues and, on completion, contact scores reflect the new config.
• AC-2: Given components were re-toggled (caps redistributed), when recompute runs, then absolute scores may shift and the new label distribution is reflected everywhere (index + detail).

— Error / Unhappy Path —
• ERR-1: Given a recompute is already running, when the admin clicks again within the window, then it is rate-limited with "recompute already running".

— Permission Model —
• CAN: CDP Admin. • CANNOT: agents.

— UI States —
• Loading: "recomputing…" status. • Success: "last computed ". • Error: rate-limit note.
[CHS-S07-NEG] — Scoring guard rails (Guard Rail — from Non-Goals)

As the system, I must enforce the MVP scope boundaries so the config stays simple and grounded.
Guard Rail• NEG-1: Given the config builder, when the admin tries to activate a second config, then only one config can be active per company (the prior active is archived) — never two active (Non-Goal 3).
• NEG-2: Given the catalog, when an NPS metric is requested, then no per-customer NPS metric is offered — NPS is out of MVP scope (satisfaction = CSAT only; Non-Goal 4 / OQ-1 / D-5).
• NEG-3: Given a company whose deal-signal ingestion has not yet landed in CDP, when building Conversion, then deal metrics are disabled with a source/availability label until CRM deal ingestion (or CEBE) ships; commerce is optional via external_activity_log (OQ-2 / D-6).
• NEG-4: Given a component, when a 4th metric is added, then it is blocked (max 3, Constraint).
• NEG-5: Given the flag is OFF, when a user opens the Customer Index, then no Health Score column/filter appears and no recompute runs.

| [CHS-S08] — View Health Score on the Inbox customer-profile panel (web + chat mobile)

As an Inbox agent, I want to see the customer's health score on the customer-profile panel while I'm in a conversation — on both the web inbox and the chat mobile app — so that I can gauge account health without leaving the Inbox. | Should Have | Figma: TBD — Inbox customer-profile panel (web + chat mobile) score chip | Cross-team display surface (additive). CDP exposes health_score / health_score_label / health_score_computed_at on the contact read-API (contact/base.go). The web inbox (hub-chat Nuxt) and the chat mobile app render a read-only score chip + coloured label on the customer-profile panel — those surfaces are owned by Omnichannel / Mobile (see §13); CDP only serves the fields. No new contact field, no new permission key. The score sits inside the customer-profile section, so its visibility is inherited from that section's view permission — no separate health-score gate; if the section is hidden, the score is hidden with it. | — Happy Path —
• AC-1: Given a contact with a computed health score, when an agent opens that customer's profile panel in the web Inbox, then the score value + coloured label chip + last-computed timestamp are shown (read-only).
• AC-2: Given the same contact, when an agent opens the customer profile in the chat mobile app, then the same score + label + timestamp are shown.
— Empty / Error —
• AC-3: Given a contact with no score computed yet, then the panel shows the "—" neutral placeholder (consistent with the CDP Index/Detail).
• ERR-1: Given the CDP read-API is unavailable, then the score field fails silently (the profile panel still renders) — no blocking error.
— Permission —
• AC-4: Given an agent without the customer-profile view permission, when they open the conversation, then the whole customer-profile section is hidden and the health score is not shown — visibility is inherited from the section, with no separate health-score permission and no partial reveal. |

| [CHS-S09] — Group customers by Health Score in Segmentation

As a CDP marketer / admin, I want to add a Customer Health Score condition when building a segment, so that I can group customers by how healthy they are (e.g. score ≥ 80, or at-risk) and target them in a campaign. | Should Have | Figma: TBD — Segment rule builder → new "Customer Health Score" field | Segmentation consumes the score as a field. Add health_score to the segment Rule Builder as a numeric field (0–100). Operators reuse the shared segment_engine Number operator set exactly — equals · not_equals · greater_than · less_than · between · is_empty · is_not_empty (§6.2; the same set the Customer Segmentation PRD defines — D-11); is_empty = not-yet-scored. Optionally also expose the derived label as a dropdown field (Healthy / Neutral / At-risk). Evaluated by the same segment_engine over the score's store-of-record (OQ-8). Jointly owned with the Customer Segmentation initiative. | — Happy Path —
• AC-1: Given the segment rule builder, when the user adds a condition, then Customer Health Score appears as a selectable numeric field.
• AC-2: Given the health-score field, when the user opens the operator list, then exactly the numeric operators are offered (equals/not_equals/greater_than/less_than/between/is_empty/is_not_empty) — no text/date operators.
• AC-3: Given health_score greater_than 80, when the segment is previewed/saved, then only customers whose current score > 80 match.
• AC-4: Given health_score between 40 and 60, when evaluated, then only customers in that band match.
— Empty —
• AC-5: Given health_score is_empty, when evaluated, then only not-yet-scored customers match (score null).
— Guard —
• NEG-1: Given the health-score flag is OFF for the company, then the health-score field is not offered in the rule builder. |

| [CHS-S10] — Health Score returned by the MAG public API (list + single customer)

As an API consumer / integrator, I want the Get all customers and Get single customer MAG endpoints to include the customer's health score, so that external systems can read it without a separate call. | Should Have | — (API only) | Public-API read passthrough. The MAG "Get all customers" (list) and "Get single customer" responses include health_score, health_score_label, and health_score_computed_at (read-only; sourced from the same stored contact fields as the CDP read-API, contact/base.go). No new endpoint — the existing customer payloads gain the fields; versioned per MAG's contract. Additive, backward-compatible; null when not yet computed. | — Happy Path —
• AC-1: Given a scored customer, when a consumer calls Get all customers on MAG, then each customer object includes health_score + health_score_label + health_score_computed_at.
• AC-2: Given a scored customer, when a consumer calls Get single customer, then the same three fields are present.
— Empty —
• AC-3: Given a customer not yet scored, then the three fields are returned as null (not omitted, not an error).
— Guard —
• NEG-1: Given the health-score flag is OFF for the company, then the fields are returned null (feature-gated). |

| [CHS-S11] — Filter contacts by Health Score in the Direct Select Contact menu

As a campaign / broadcast user, I want to filter contacts by health score in the direct Select Contact menu, so that I can pick recipients by account health when composing. | Should Have | Figma: TBD — Select Contact menu → Health Score filter | Consumes the stored score as a filter. The direct Select Contact recipient picker gains a health-score filter — by numeric range/operator (the same segment_engine Number set) and/or by label (Healthy / Neutral / At-risk). Maps to a stored-field query over the score's store-of-record (OQ-8), like the Customer Index label filter (behaviour #6). Read-only consumption; no new field. | — Happy Path —
• AC-1: Given the Select Contact menu, when the user opens filters, then a Health Score filter is available.
• AC-2: Given the user filters health_score greater_than 70 (or label = Healthy), when applied, then only matching contacts are listed as selectable recipients.
— Empty —
• AC-3: Given no contact matches, then the list shows the empty state (no error).
— Guard —
• NEG-1: Given the health-score flag is OFF, then the Health Score filter is not shown in the Select Contact menu. |

| [CHS-S12] — Health Score exposed via the S2S customer-details API

As another internal service, I want to read a customer's health score through the S2S Get customer details API, so that my service can use it without duplicating the scoring logic. | Should Have | — (API only) | Service-to-service read passthrough. The internal S2S "Get customer details" response includes health_score, health_score_label, health_score_computed_at (read-only, from the stored contact fields). Same store-of-record as the read-API / MAG; additive to the existing S2S payload; null when not computed; feature-gated per company. | — Happy Path —
• AC-1: Given a scored customer, when another service calls S2S Get customer details, then the response includes health_score + health_score_label + health_score_computed_at.
— Empty —
• AC-2: Given a customer not yet scored, then the three fields are null (not an error).
— Guard —
• NEG-1: Given the health-score flag is OFF for the company, then the fields are null. |

Dependencies: CHS-S01/S02 → health_score_config collection + config UI; CHS-S03 → daily recompute job + SignalAggregator + per-contact metrics (§13); CHS-S04/S05 → contact read API returns stored score/breakdown + FE column/detail; CHS-S06 → manual recompute trigger; CHS-S08 → Inbox display surfaces (web + mobile), cross-team (§13); CHS-S09 → Segmentation numeric condition; CHS-S10 → MAG public API passthrough; CHS-S11 → Direct Select Contact filter; CHS-S12 → S2S customer-details passthrough (all §13). All scoring stories depend on the active config from CHS-S01/S02; all consumption stories (S09–S12) depend on the stored health_score* fields from CHS-S03/S04.

9. Rollout

FieldDetail
Flagcdp_customer_health_score_enabled | default: OFF — per-company enable by Ops.
Stage 0Schema additive: health_score* fields on contact + health_score_config collection + read-API passthrough. No behaviour change (fields null).
Stage 1 — Internal QA1–2 synthetic tenants. Verify config validation (caps=100, ≤3 metrics, bands), recompute correctness (component clamp, label mapping), signal aggregation per source, performance.
Stage 2 — Closed Beta3–5 CS/Sales-heavy tenants with the default template. Validate the default template resonates; tune preset metrics/points; confirm score explainability lands with users.
Stage 3 — GAProgressive per-tenant enable (Growth + Enterprise).

9.1 Migration Transition Window

  • Before first recompute: contacts have null health_score → index shows "—", detail shows "not yet computed". No error state; the feature is simply "pending".
  • After first recompute: scores populate; the column/breakdown activate.
  • Config change / component re-toggle: because caps redistribute to total 100, a contact's absolute score can shift after a change. The next recompute (or "Recompute now") re-scores consistently; the admin is warned "changing components will re-score all contacts".

9.2 Semantic Regression Rollback

The score is a computed output that can look valid but be worse (e.g. a bad config change skews everyone to "At-risk", or a source starts returning wrong aggregates). Rollback mechanism:

  • Config-level: each health_score_config version is retained; revert to the prior active version (one click) and recompute. This is the primary rollback.
  • Feature-level flag: cdp_customer_health_score_enabled OFF hides the score everywhere and pauses recompute (scores retained but not shown).
  • Trigger threshold: if a recompute shifts >40% of contacts into a different label band vs the previous run, the job flags a health_score_distribution_shift alert and holds the write pending admin confirmation (auto-revert to last good scores until acknowledged) — prevents a bad config silently re-labelling the whole base.

10. Observability

EventTriggerPropertiesAlert
health_score_config_savedAdmin saves/activates a configcompany_sso_id, active_components, metric_count, bands
health_score_recompute_completedRecompute job finishescompany_sso_id, contacts_scored, label_distribution, duration_ms, trigger (daily/manual)duration > 4h → CDP on-call
health_score_signal_missingA signal source unavailable for a contactcompany_sso_id, contact_id, source (deals/tickets/csat/calls/convo/campaign)> 5% of contacts in a run → notify CDP Squad (source degraded)
health_score_partial_sourceWhole source unavailable for a runcompany_sso_id, sourceany → CDP Squad
health_score_distribution_shift>40% of contacts change label band vs prior runcompany_sso_id, shift_pct, from/to distributionany → hold write + notify PM + Admin (rollback guard, §9.2)
cdp_health_score_changedMaterially changed score/label written (optional, per contact — sampled)company_sso_id, contact_id, old_label, new_label— (future automation hook)

Dashboard owner: CDP Squad. Cadence: daily review for the first 2 weeks of Beta, then weekly for the first month post-GA. Investigate immediately on any health_score_distribution_shift or recompute duration breach.

11. Success Metrics

MetricDefinitionBaselineTarget
⭐ CDP stickiness / adoption (North Star)% of eligible (Growth/Enterprise) tenants with an active health-score config within 60 days of GA0 (feature doesn't exist)≥ 30% within 30 days of GA
Feature engagement% of active-config tenants whose users use the label filter on the Customer Index weeklyN/A≥ 50% of active-config tenants
Explainability / trust% of surveyed beta users who agree "I understand why a customer got its score"N/A≥ 80%
Config health% of active configs that keep the default template vs customise (signals template fit)N/ATrack (no target — informs template tuning)
Recompute reliabilityRecompute runs completing within SLA without partial-source alertsN/A≥ 99% of daily runs within 4h; < 1% partial-source
Prioritisation impact (secondary)Correlation of "Healthy" label with subsequent won deals (validation sample)N/APositive correlation demonstrated in beta review

12. Launch Plan & Stage Gates

StageAudienceDurationSuccess GateOwner
Stage 0 — Schema & configEngineering1–2 sprintshealth_score* fields + health_score_config + read-API passthrough live; flag wired; recompute job in staging; zero behaviour change.CDP Eng
Stage 1 — Internal QA1–2 synthetic tenants1 weekConfig validation, component clamp + label mapping correct; each signal source aggregates correctly; recompute within SLA; rollback (config revert + distribution-shift hold) verified.QA
Stage 2 — Closed Beta3–5 tenants2–3 weeksDefault template validated with real users; explainability ≥80%; no distribution-shift incidents from product bugs; preset metrics tuned.PM + CS
Stage 3 — GAProgressive Growth/EnterpriseOngoingAdoption tracking on; recompute reliability ≥99%; no open high-priority bugs.PM + Ops

13. Dependencies

DependencyOwnerDeliverableBlocking?
health_score_config collection + config CRUDCDP BackendCompany-scoped config (components, metrics, bands, one active) — modelled on CustomerSegment (segment/segment.go).YES
health_score* fields on the contact doc + read-API passthroughCDP Backendhealth_score, health_score_components, health_score_label, health_score_computed_at (contact/base.go).YES
Daily recompute jobCDP Backendgocraft/work cron (reuse worker_service.go PeriodicallyEnqueue + BackfillChatContact pattern) + manual "recompute now".YES
Field-attribute rule evaluation — shared operator engineCDP BackendReuse the segment_engine operator logic per field type (text / dropdown / number / date / boolean — segment.go:34–52) — the same set the Customer Segmentation — Basic Attributes PRD defines, for default and custom fields (D-11). One engine serves both Segmentation and Health Score.YES
Per-customer use-case signals — ingestion from CRM + omnichannelCDP Backend + CRM + Omnichannel (+ optionally CEBE)Deals / tickets / CSAT / WA-calls are grounded in the CRM (qontak.com) per crm_person (person.rb, csat.rb, call_recordings); campaign reply/rate/type + WA-call activity in the omnichannel backend (hub-chat). Conversation recency + campaign delivered/read are already in CDP (activity_log). The not-yet-ingested signals need a new/extended CDP consumer (today's Kafka consumers carry only status enums — crm_event_payload.go:10–16) or the CEBE metrics layer as the standardized path (§6.2; D-10).YES (partially reachable now)
Score store-of-record + segment_engine reconciliationCDP BackendDecide where health_score/label live and which store the Index label-filter queries: the Postgres customer_datamart (where segment_engine + segmentation already run — engine.go:19,320) vs the MongoDB contact doc (contact/base.go:50+). The two are separate stores; picking wrong forces a rewrite of the recompute writer or the Index filter. See OQ-8.YES
Config builder + Index column/filter + Detail breakdownCDP FE (qontak-customer-fe)Config UI, /customers score column + label filter (pattern like existing filterByRoute), detail breakdown panel.YES
Inbox (web) customer-profile panel — score displayOmnichannel / hub-chat (FE)Render the read-only health_score chip + label + computed_at on the customer-profile panel in the web inbox, from the CDP contact read-API (CHS-S08).Not blocking the engine; gates the web-inbox display surface only
Chat mobile app — customer-profile score displayMobile (chat app)Render the same score chip + label on the mobile inbox customer-profile panel, from the CDP contact read-API (CHS-S08).Not blocking the engine; gates the mobile display surface only
Segmentation — Health Score condition in the Rule BuilderCDP FE (qontak-customer-fe) + shared segment_engineAdd health_score as a numeric field (+ optional label dropdown) to the segment Rule Builder, numeric operator set per D-11; evaluated by the same engine over the score store-of-record (OQ-8). Jointly owned with Customer Segmentation.Not blocking the engine; gates the segmentation consumption (CHS-S09)
MAG public API — score passthroughCDP BackendAdd health_score* to the MAG Get all / Get single customer payloads (read-only, feature-gated).Not blocking the engine; gates the public-API consumption (CHS-S10)
Direct Select Contact — health-score filterCDP FE + BackendHealth-score numeric/label filter in the recipient Select Contact menu, mapped to a stored-field query.Not blocking the engine; gates the recipient-filter consumption (CHS-S11)
S2S customer-details — score passthroughCDP BackendAdd health_score* to the internal S2S Get customer details payload (read-only, feature-gated).Not blocking the engine; gates the S2S consumption (CHS-S12)
Commerce (purchase) metrics (optional)CDP + IntegrationsOrders/payments reach CDP now via external_activity_log where a Loyalty/POS integration posts them (amount in attributes); otherwise CRM deal value proxies spend. A dedicated LTV/AOV/purchase-freq feed can later come via CEBE Conversion (OQ-2).NO (optional)
NPS-per-customer (out of MVP)Not modelled in MVP — the CRM has only per-agent NPS (users.last_nps_submission); per-customer NPS is a later-phase addition (OQ-1).NO (out of scope)
Figma designsDesignConfig builder + score display.YES (Stage 1)

📊 Dependency Graph

graph LR
F[Customer Health Score] -->|BLOCKING| CFG[health_score_config collection + CRUD]
F -->|BLOCKING| FLD[health_score* fields on contact + read-API passthrough]
F -->|BLOCKING| JOB[Daily recompute job - gocraft/work cron]
F -->|BLOCKING - shared with Segmentation| EVAL[Field attributes: segment_engine per fieldType over customer_datamart - now]
F -->|BLOCKING| SIG[Per-customer use-case signals: deals/tickets/CSAT/calls + campaign]
F -->|BLOCKING| STORE[Score store-of-record: customer_datamart vs Mongo contact doc]
F -->|BLOCKING| FE[customer-fe: config UI + Index column/filter + Detail breakdown]
F -->|Stage 1| FIG[Figma designs]
CRM[CRM qontak.com: deals / tickets / CSAT / call_recordings per crm_person] -->|ingest via consumer or CEBE| SIG
OMNI[omnichannel / hub-chat: campaign reply/type + WA-call] -->|ingest via consumer or CEBE| SIG
CDPNOW[CDP now: activity_log conversation + campaign delivered/read; external_activity_log orders] --> SIG
COM[Commerce optional: external_activity_log now / CEBE Conversion later] -.-> SIG

14. Key Decisions + Alternatives Rejected

14a — Decisions Made

Decisions dated 2026-07-08, grounded in the codebase review (Appendix A) + benchmark (Appendix B).

IDDecisionRationale (grounded)
D-1Numeric 0–100 score with a derived label, not a fixed Hot/Warm/Cold label.The Signal Label PRD deferred numeric scoring; clients want a tunable score. The label is just a band over the score, so numeric subsumes the label with more expressiveness.
D-2Rule-based, admin-configured — NOT ML.Zoho Zia (Appendix B) is powerful but requires training data (per the benchmark board) and is a black box. A transparent rule engine works day-1, is explainable, and matches the Signal Label decision to avoid ML complexity for MVP.
D-35 default components, each cap 20; components on/off with cap auto-redistribution to 100; ≤3 metrics/component.Matches the requested model and mirrors Gainsight/HubSpot weighted-measure scorecards. The ≤3-metrics cap and fixed component structure keep MVP complexity bounded.
D-4Reuse segment_engine operators + CustomerSegment config shape + the gocraft/work cron mechanism.segment_engine (engine.go) implements the exact operators the Field-Attribute rules need (over Postgres customer_datamart); CustomerSegment (segment/segment.go:74–89) already models a company-scoped rule set with LastEvaluatedAt/RecalcErrorCount; gocraft/work cron exists (worker_service.go:13,49–52,74). Grounding corrections: (1) there is no existing scheduled all-contact evaluator to clone — the daily recompute job is net-new (only the mechanism is reused); (2) segment_engine queries Postgres customer_datamart, while the contact doc is Mongo — the score's store-of-record is an open decision (OQ-8); (3) the use-case component signals are sourced from their systems of record — CRM (deals/tickets/CSAT/calls) + omnichannel (campaign/WA-call) — ingested via a new/extended CDP consumer or the CEBE metrics layer, not a bespoke one-off aggregate (D-10/OQ-5).
D-5Satisfaction = CSAT only for MVP; NPS out of scope.Per-customer CSAT is grounded in the CRM (csats avg/recency per crm_person, csat.rb). Per-customer NPS does not exist in the CRM today (only per-agent users.last_nps_submission), so NPS is dropped from the MVP rather than modelled speculatively — revisit in a later phase (OQ-1).
D-6Conversion from CRM deals (incl. won deal rate) + elaborated commerce.Won count / won deal rate (won ÷ associated) / won value / LTV / recent-won grounded per crm_person in the CRM (crm_deals × crm_stages.win_probability × crm_people_deals; compute_deal_aggregates_usd, person.rb:742–791). Commerce — total purchases, total purchase amount, last purchase, avg order value — from external_activity_log order/payment events (order_confirmed/payment_confirmed, amount in Attributes) where a Loyalty/POS integration posts them; else deal value proxies spend (OQ-2). Lost-deal penalty dropped per PM feedback (a low win-rate already reflects it).
D-7Score stored on the contact (not computed at read).Customer Index must render at scale with no added latency; a stored field + daily bulk recompute meets the performance constraint.
D-8One active config per company for MVP.Keeps the mental model and UI simple; multiple parallel schemes add config/version complexity without validated demand.
D-9Distribution-shift guard on recompute (>40% band change holds the write).A scoring output can regress silently; the guard prevents a bad config from re-labelling the whole base unnoticed (semantic-regression rollback, §9.2).
D-10Reuse shared substrates; source from systems of record; ingest via consumer or CEBE. Field Attributes → segment_engine over customer_datamart (shared with Segmentation). Use-case signals → their real systems of record: CRM (deals/tickets/CSAT/calls per crm_person) + omnichannel/hub-chat (campaign reply/type, WA-call); conversation + campaign-delivered already in CDP.Grounded in Appendix A. The not-yet-ingested signals need a new/extended CDP consumer (today's Kafka consumers carry only status enums) or the CEBE metrics layer as the standardized aggregate path — CEBE is an ingestion option, not the data source (the earlier "everything via CEBE" framing was corrected here). Still aligns with CEBE (which names Health Score a downstream consumer) and Segmentation (shared operator engine); it just doesn't pretend CEBE is where the data originates.
D-11Field-attribute operators reuse the shared per-field-type operator logic — the Customer Segmentation "Operator Logic by Default/Custom Field" set = segment_engine (segment.go:34–52), for default and custom fields alike.One operator vocabulary across Segmentation + Health Score config; no divergent operator sets to maintain; a client configuring a segment and a health-score rule sees the same operators per field type (§6.2 table).

14b — Alternatives Rejected

AlternativeWhy Rejected
Keep label-only (Hot/Warm/Cold)Too coarse; clients want a tunable score and explainability. Label is retained as a derived band of the numeric score.
ML/AI predictive score (à la Zoho Zia)Requires training data + is a black box; fails the explainability need and the MVP-complexity bar. Candidate for a later phase.
Real-time (per-event) recomputeHigh infra cost; daily batch meets the prioritisation use case. Real-time is a later phase.
Free-form formula language for metricsUnbounded complexity + validation/security burden; a curated catalog (≤3/component) is safer and easier to reason about.
Compute score at read timeWould add latency to the Customer Index at scale; violates the performance constraint. Stored field + batch recompute chosen.
Multiple active configs per companyConfig/version/UX complexity with no validated MVP demand; one active config chosen.
Build a bespoke one-off CRM→CDP aggregate path in this PRDDiverges from the shared CDP data plumbing; instead ingest each signal from its system of record via a new/extended CDP consumer or the CEBE metrics layer (D-10) — reuses existing infrastructure and stays aligned with Segmentation + CEBE.
Build on a separate CS tool (Gainsight-style) integrationThe signals (convo, deals, tickets, CSAT) already live in Qontak; building natively avoids an external dependency and keeps the score where the data is.

15. Open Questions

#TypeQuestionMitigation / DefaultOwnerDeadline
OQ-1DecisionNPS-per-customer is out of MVP scope. The CRM has only per-agent NPS (users.last_nps_submission), no per-customer NPS. MVP satisfaction = CSAT only (CRM csats). Should per-customer NPS be built later (own capture, or via CEBE Communication)?Ship CSAT-only for MVP (D-5); revisit NPS post-MVP.PMPost-MVP
OQ-2DecisionCommerce (purchase) metrics — total purchases, total purchase amount, last purchase, avg order value — come from external_activity_log order/payment events where a Loyalty/POS integration posts them; otherwise CRM deal value proxies spend. A dedicated feed can come later via CEBE Conversion. Which tenants have the integration, and is commerce in the default template?Include commerce metrics when the integration is present (else hidden); off by default in the template.PMPre-RFC
OQ-3RiskCampaign-reply linkage. WhatsApp-broadcast logs have no direct crm_person_id (phone/bsuid linkage); email delivery_stats is polymorphic. Reply/open aggregation per contact may be partial (CEBE Campaign will standardise this).Use email reply + conversation recency as primary engagement; treat WA-broadcast reply as best-effort until CEBE Campaign firms up linkage.CDP Eng + CEBEPre-RFC
OQ-4DecisionCap redistribution rounding. With N active components, 100/N isn't always integer (e.g. 3 → 33/33/34). Confirm the remainder rule (proposed: remainder → first component; caps always total 100).Default: remainder to the first component; document in the builder.PM + EngPre-RFC
OQ-5DecisionIngestion path for the CRM + omnichannel signals. The signals are grounded (CRM per crm_person; omnichannel/hub-chat) but are not in CDP today (the Kafka consumers carry only status enums — crm_event_payload.go:10–16). Deliver via new/extended CDP consumers or the CEBE metrics layer?Prefer CEBE where it already standardises the metric; extend a direct CDP consumer where CEBE hasn't shipped that module. Confirm per-signal in the RFC. Field Attributes + conversation + campaign-delivered are buildable now.CDP + CEBEPre-RFC
OQ-8DecisionScore store-of-record. segment_engine runs on Postgres customer_datamart; the contact doc is MongoDB. Where do health_score/label live, and which store does the Index label-filter query?Proposed: store on customer_datamart (co-located with segmentation + the Index filter), mirror onto the contact API response. Confirm in RFC.CDP EngPre-RFC
OQ-6AssumptionTeam-owner / permission scope. Score visibility should respect existing contact permission + team-owner scoping (the Team Owner initiative). Assumed: score inherits contact visibility.Confirm with the Team Owner/permission model; no separate score-level permission in MVP.PMPre-RFC
OQ-7Open QuestionScore history / trend. MVP stores only the current score. Do users need a trend (score over time)?Deferred; a status/ trend store is a later phase if demand appears.PMPost-beta

Appendix A — Grounded Code References

contact-service (CDP · Go / MongoDB) — the home of the score

  • Contact model + custom fields: internal/app/repository/contact/base.go:49–110 (contact doc), :199–210 (CustomField: Key, Value, FieldType, Datatype, ValueID). Field types: single_line_text, text_area, dropdown_select, number, date, upload, multiple_select, url, gps, signature. → Field Attributes component source (✅).
  • Field-properties catalog: internal/app/repository/field_properties/base.go:352–382 (Name, FieldType, Datatype, Dropdown options, per-type validation; 15 default system fields incl. status, source, job_title, avg_annual_income, address). → catalog of selectable fields.
  • Rule/operator engine (reuse): internal/pkg/segment_engine/engine.goIRuleEngine.BuildQuery (interface.go:15); operators is / is_not / contains / greater_than / less_than / between / on / before / after / is_empty …; field categories System/Default/Custom; type-aware casting; SQL-injection allowlist. → metric-rule evaluation substrate (✅ reuse) — the same engine Customer Segmentation uses (D-11). Note (grounding): it builds SQL WHERE against the Postgres customer_datamart table (engine.go:19,320; JSONB default_fields/custom_fields), not the Mongo contact doc → store-split (OQ-8).
  • Config precedent (reuse shape): internal/app/repository/segment/segment.goCustomerSegment (company-scoped RuleSet → RuleGroup → RuleCondition, Status active/archived, LastEvaluatedAt, RecalcErrorCount). → health_score_config modelled on this.
  • Scheduler (reuse): internal/worker/worker_service.go:13–98gocraft/work pool + PeriodicallyEnqueue(cron, job); example cron internal/app/cron/backfill_chat_contact.go. → daily recompute job.
  • Activity ingestion: internal/app/repository/activity_log/base.go:17–34 (category, customer_id, timestamp, associated_type, Attributes interface{}); Kafka consumers present in internal/app/kafka_consumer/: deal_consumer.go, ticket_consumer.go, campaign_consumer.go, room_consumer.go, task_consumer.go, company_consumer.go, field_consumer.go, loyalty_member_enrollment.go, marketing_opt_in.go, workflow_approval_consumer.go. → conversation (room) + campaign events present and aggregatable; but no per-contact numeric aggregates stored (it's an event log — CEBE is the metrics layer over these).
  • ⚠️ Missing data paths (grounding — today's state; resolved by CRM/omnichannel ingestion): No csat consumer and no call/call_recording consumer exist in contact-service (grep returns none) → CSAT + calls are not in CDP directly today. The deal/ticket consumers only accept statuses linked/unlinked/resolved (deal_consumer.go:12, ticket_consumer.go:12), and the CRM event payload CrmChangeData = {id, name, qontak_customer_id, user_id, status} only (crm_event_payload.go:10–16) → deal value/size, won-vs-lost, stage, and ticket priority are not carried into CDP directly. CreateActivityLogForDeal stores Category + AssociatedName, no amount (activity_log.go:351,423–425). This is why Conversion, Support, and Satisfaction signals are ingested from their systems of record (CRM + omnichannel) via a new/extended CDP consumer or the CEBE metrics layer (D-10/OQ-5/§13) — not re-aggregated ad-hoc inside contact-service.
  • Commerce/loyalty events: internal/app/repository/external_activity_log/model.go:13–51 — source external/internal, category transaction, actions order_confirmed/payment_confirmed (amount in Attributes). → commerce reaches CDP now via a Loyalty/POS external_activity_log; a dedicated LTV/AOV/purchase-freq feed can later come via CEBE Conversion (OQ-2/D-6).
  • Config precedent (company settings): internal/app/repository/webhook_config/base.go:16–26 (company-scoped config CRUD).
  • No existing per-contact score field anywhere → health_score* is net-new on contact/base.go.

qontak.com (CRM · Rails) — system of record for deals / tickets / CSAT / calls

These signals live in the CRM, per crm_person, and are fully per-customer aggregable (helpers cited below) — but are NOT reachable from CDP directly today (no consumer / not in the CRM→CDP event payload — see the contact-service "Missing data paths" note above). They are ingested into CDP via a new/extended CDP consumer or the CEBE metrics layer (§13, D-10/OQ-5). CEBE is an ingestion path, not where the data originates.

  • Deals (Conversion — CRM system of record; ingest via consumer/CEBE): app/models/crm/deal.rbcrm_deals.size (value, multi-currency), crm_stage_id → Crm::Stage.win_probability (1 = won, 0 = lost). Per-person via crm_people_deals join (person.rb:114–115); helpers Crm::Person#get_won_deals (person.rb:59–63), #compute_deal_aggregates_usd (person.rb:742–791). → won count, won rate (won ÷ associated), won-value sum / LTV, lost count.
  • Tickets (Support — CRM system of record; ingest via consumer/CEBE): app/models/crm/ticket.rb + ticket_stages.win_probability (=1 → resolved) + ticket_priority (Low/Medium/High/Critical); per-person via people_tickets join (person.rb:128–129, ticket.rb:74). → open count, has-open (boolean), high-priority open, ticket volume, last-opened recency, aging-unresolved, resolved ratio — all customer-POV.
  • CSAT (Satisfaction — CRM system of record; ingest via consumer/CEBE): app/models/csat.rb (Crm::Person has_many :csats, person.rb:152) + db/migrate/20210428041644_create_csat.rbcsats.crm_person_id, option_value (numeric), selected, selected_at. Avg per person: Csat.where(crm_person_id:, selected: true).average(:option_value). → avg CSAT + recency + count. CSAT is the only satisfaction signal in MVP.
  • NPS (out of MVP): in qontak.com today only users.last_nps_submission / nps_count exist — per-agent, not per-customer. There is no per-customer NPS, so NPS is dropped from the MVP (satisfaction = CSAT only). → OQ-1/D-5.
  • WA / voice calls (Engagement — CRM call_recordings + omnichannel; ingest via consumer/CEBE): app/models/call_recording.rb (belongs_to :contact, foreign_key: crm_person_id, line 10) + migrations — crm_person_id, call_time, started_at, duration, call_type. → call count / recency / duration / direction per contact. Also surfaced in the omnichannel WA-call backend (see hub-chat below).
  • Campaign (Engagement — email in CRM; WA in omnichannel): email campaigns + delivery_stats (polymorphic reference_object; scopes for opened/replied); WhatsApp broadcast delivery/reply lives in the omnichannel backend (see hub-chat below). → email reply derivable in CRM; WA broadcast delivered/read already in CDP, reply/type via omnichannel (OQ-3).
  • Commerce (optional — not in CRM): no purchase/order ledger in qontak.com (only Crm::DealsTransaction, deal metadata). Commerce reaches CDP via external_activity_log (a Loyalty/POS integration) now, or a later CEBE Conversion feed; else CRM deal value proxies spend. → OQ-2/D-6.

qontak-customer-fe (Nuxt) — display surfaces

  • Customer Index list + filterByRoute scoping pattern → score column + label filter hook point.
  • Customer Detail page → breakdown panel hook point.
  • Contact type extended with health_score* fields once the API returns them.

Omnichannel / hub-chat — conversation, campaign & WA-call signals

(hub-chat is the Nuxt frontend; the signals below are served by the omnichannel/WhatsApp backend it consumes — grounded via the hub-chat type contracts.)

  • Conversation / room: room events flow into CDP as activity_log category room (via room_consumer.go) → conversation count + recency reachable now (✅). Rooms are keyed to a customer via qontak_customer_id (hub-chat RoomDetail), with last_message_at / last_activity_at for recency.
  • Campaign / broadcast: the WA/omnichannel backend holds message_status_count {sent, delivered, read, failed, pending}, RecipientMessage.status, messages_broadcast_id keyed by contact_phone_number / contact_id, and message_template.category (MARKETING / UTILITY / AUTHENTICATION) → delivered rate, reply rate, # replies, last delivered / last reply, reply type broadcast-vs-utility. CDP today ingests only campaign delivered/read (campaign_consumer.go) — reply + type need an extended consumer or CEBE Campaign (OQ-3/OQ-5).
  • WhatsApp calls: WA-call activity carries contactId, callDuration, finalStatus, startDialTime, and direction (USER_INITIATED = inbound / BUSINESS_INITIATED = outbound) → call count / recency / duration / direction. Not in CDP today (mirrors CRM call_recordings).

Appendix B — Competitive Benchmark

Source: the uploaded benchmark board (Zoho Zia, HubSpot, Salesforce, Gainsight) + public product docs. The board's own outline confirms the competitor set and Zia's multi-score model.

ProductScoring modelComponents / measuresConfigurabilityTake-away for Qontak
Zoho CRM (Zia Scores)AI/ML-predicted, multi-scoreCustomer Health score (satisfaction, usage patterns), Engagement score (module/record activity), Follow-up score (timeliness, relevance, impact), Conversion score (journey-based: Leads/Deals). Also field-attribute signals on the record.Predefined score types; requires training data; shown as a % gauge + level ("Need improvement / Excellent").Validates the multi-component model (Qontak's 5 map onto Health/Engagement/Follow-up/Conversion + field attributes). But Zia is a black box needing training data → Qontak differentiator = transparent, rule-based, works day-1.
HubSpotCustomer Health score in the Customer Success workspaceHealth score built from properties/usage; segment/health views.Property-driven scoring; workspace surfaces health.Confirms health-score is a CS workspace staple; reinforces the Customer Index column + filter and detail breakdown UX.
SalesforceHealth/scoring gauges (Einstein + custom)Gauge-style health + scoring on the account/contact.Config + Einstein options.Validates the gauge + label display and the score-on-record pattern.
GainsightScorecards (the CS gold standard)Weighted measures grouped into an overall health score with coloured labels (e.g. red/yellow/green); each measure weighted.Fully configurable measures + weights + colour scheme.Directly validates Qontak's model: weighted components + capped contribution + configurable coloured label bands. Qontak's edge: it lives in the same platform as the conversations/deals/tickets/CSAT, so no separate CS tool/integration is needed.

Positioning (why Qontak's approach wins for MVP):

  1. Transparent & rule-based — every point is explainable on the Customer Detail breakdown (vs Zia's black-box ML).
  2. Works day-1 — no ML training data required; the default 5-component template ships preset.
  3. Native to the data — signals (convo, calls, deals, tickets, CSAT) already live in Qontak; no external CS-tool integration (vs standalone Gainsight/Totango).
  4. Configurable like Gainsight — components on/off, curated metrics (≤3 each), custom label bands — without Gainsight's cost/complexity.

PRD CHANGELOG

VersionDateBySectionTypeSummary
1.72026-07-15Permission emphasis + downstream consumers§6, §7, §8.2 (CHS-S08/S09/S10/S11/S12), §13ADDED(1) Permission emphasis — the score renders inside the customer-profile section; visibility is inherited from that section's view permission (no separate health-score gate; section hidden → score hidden). Reinforced in the §6 intro + CHS-S08 (new AC-4). (2) Downstream consumers — four new Should Have stories + §7 behaviours #8–#10 + four §13 dependencies: CHS-S09 segment by health score (numeric segment_engine Number operators, per D-11; optional label dropdown); CHS-S10 health_score* on the MAG public API (get-all + single customer); CHS-S11 health-score filter in the direct Select Contact menu; CHS-S12 health_score* via the S2S customer-details API. All read-only passthroughs of the stored fields; feature-gated; null when not computed. Mirrors Confluence page 51135449719 (v1.7).
1.62026-07-09Inbox display scope§6, §8.2 (CHS-S08), §13ADDEDNew scope: the Customer Health Score is displayed (read-only) on the Inbox customer-profile panelweb inbox (hub-chat) and chat mobile app. Added story CHS-S08, a §6 display-surface note, and two §13 cross-team dependencies (Omnichannel/hub-chat FE + Mobile chat). CDP serves the health_score* fields via the contact read-API; the Inbox/mobile surfaces render them — no new field, no new permission key. Mirrors Confluence page 51135449719 (v1.6).
1.52026-07-09Metric refinements (PM feedback)§6.1 (worked example), §6.2 (Components 3 & 4), §14 (D-6), §15 (OQ-2)UPDATEDWorked example now shows a fully-healthy customer on the default template — every component fills its cap of 20 → total 100 Healthy (Field Attributes uses phone / email / username is_not_empty + job title + source; no VIP / Active rows). Conversion: removed Lost-deal penalty; elaborated commerce into total purchases, total purchase amount, last purchase, avg order value (via external_activity_log). Support: removed Aging-unresolved-ticket + Open-high-priority-ticket (kept No-open-ticket, Recent-complaint, Issues-get-resolved, Ticket-volume). D-6 + OQ-2 updated accordingly.
1.42026-07-09Metric-catalog rework (PM feedback)§5, §6 (tree/model/constraints), §6.2 (full rewrite), §7, §8, §13 (+graph), §14 (D-5/6/10), §15 (OQ-1/2/5), Appendix AUPDATEDReworked the metric catalog per PM feedback, and re-grounded every source against the real repos (contact-service, qontak.com CRM, hub-chat omnichannel) instead of blanket-CEBE. (1) Caps are auto-redistributed and client-editable (§5/§6.1). (2) Field Attributes table restructured: the per-field-type operator table now lives inside Component 1, and each field type carries its own example rule → points (default + custom). (3) Engagement elaborated — campaign delivered rate / reply rate / # replies / last delivered / last reply / reply type (broadcast vs utility) + WA-call count/recency/duration/direction, all with time windows; inactivity penalty removed. (4) Conversion — added won deal rate (won ÷ associated) alongside won count / LTV / lost-penalty; commerce optional. (5) Support — dropped low-resolution-rate / SLA / repeat-issue; rebuilt around customer-POV metrics (open/aging/high-priority/volume/recency/resolved-ratio). (6) Satisfaction = CSAT only; NPS + negative-experience-trend removed (NPS out of MVP — CRM has only per-agent NPS). (7) Sources re-grounded: attributes via segment_engine (now); conversation + campaign-delivered in CDP now; deals/tickets/CSAT/WA-calls = CRM qontak.com per crm_person (person.rb/csat.rb/call_recordings); campaign reply/type + WA-call = omnichannel hub-chat; ingested via a CDP consumer or CEBE — CEBE is one ingestion path, not the data source (D-10). OQ-1 (NPS out), OQ-2 (commerce optional), OQ-5 (ingestion path) reopened as decisions; bumped to v1.4.
1.32026-07-08Align to CEBE + Segmentation§4, §5, §6.2, §7, §8, §13 (+graph), §14 (D-4/5/6 + D-10/11), §15 (OQ-1/2/5 resolved), Appendix A, frontmatter (related)UPDATEDCross-initiative alignment pass (all sections reconciled to remove the pre-alignment "P0 gap / NPS-doesn't-exist / commerce-proposal-only" framing — §4 Non-Goal 4, §7 behavior #5, §8.1 step 4, CHS-S01 AC-5, S07 NEG-2/3, the §13 dependency graph nodes, and Appendix A grounding notes now all point at CEBE phasing). (1) Field Attributes now explicitly reuse the shared segment_engine operator logic per field type — the same set the Customer Segmentation — Basic Attributes PRD defines ("Operator Logic by Default/Custom Field"), for default and custom fields; added the operator-by-field-type table (§6.2) + D-11. (2) Use-case components (Engagement/Conversion/Support/Satisfaction) retagged to source from CEBE per-customer metrics (CEBE ANCHOR §6–7 names Health Score a downstream consumer) — added D-10 + a §6.2 alignment callout. (3) This resolves the earlier P0 (OQ-5 CRM→CDP aggregate path = CEBE) and un-defers NPS (CEBE Avg NPS, Q4 2026, OQ-1) and commerce (CEBE Conversion LTV/AOV, Q1 2027, OQ-2) — all three OQs marked ✅ Resolved. (4) Availability now phased to CEBE §8 (Comms/Ticket Q4 2026; Deals/Commerce/Loyalty Q1 2027; attributes + convo/campaign now). §13 deps + graph reframed to CEBE + shared operator engine; §5 data-availability row rewritten; related: → CEBE + Segmentation. No change to the 5-component scoring model.
1.22026-07-08score-prd follow-ups§1 (one-liner), §3 (personas), §6 (UI-state diagram), §13 (dependency graph)UPDATEDApplied the top score-prd improvement items: trimmed the one-liner to ≤25 words; merged the three secondary personas (Sales/Marketing/CS) into one "Score consumer" persona (1–3 rule); added a UI-state stateDiagram for the config-builder/score lifecycle (§6) and a Dependency Graph (§13) surfacing the P0 CRM→CDP aggregate path + store-of-record edges. No scope/model change.
1.12026-07-08ground-prd pass§5, §6.2, §13, §14 (D-4), §15 (OQ-5/OQ-8), Appendix AUPDATEDGrounded fixes (P0/P1) from the ground-prd codebase pass. Corrected the over-claim that deals/tickets/CSAT/calls are "available today": grounding confirmed contact-service has no CSAT/call consumer and the CRM→CDP event payload carries only {id,name,qontak_customer_id,user_id,status} (crm_event_payload.go:10–16) — so Conversion (deal value/won-lost/stage), Support (ticket priority/stage), Satisfaction (CSAT), and the WA-call metric are not reachable from CDP today. Escalated OQ-5 to a P0 blocker and added it as the top §13 dependency (CRM→CDP aggregate path gates 3 of 5 components). Added OQ-8 + a §13 dependency for the store-of-record split (segment_engine runs on Postgres customer_datamart, contact doc is Mongo). Corrected D-4 (recompute job is net-new; only the cron mechanism is reused). Added a ⚠️ Grounding reality callout to §6.2 and expanded Appendix A with the consumer-absence + payload-shape + customer_datamart evidence. No scope/model change — only accuracy of what's buildable-now vs blocked.
1.02026-07-08Zhelia Alifa (drafted w/ AI, grounded)AllCREATEDFirst grounded PRD for Customer Health Score — evolves the label-only Customer Signal Label discovery (page 51135449719) into a numeric 0–100 rule-based score with a derived, client-configurable label. Defines the 5-component model (Field Attributes · Engagement · Conversion · Support · Satisfaction/CSAT), each capped at 20 with components on/off + cap auto-redistribution and ≤3 metrics/component; a curated, grounded metric catalog (§6.2) with a default template; daily recompute (reusing gocraft/work + segment_engine + CustomerSegment config shape); config/index/detail UI; distribution-shift rollback guard. Grounded against contact-service, qontak.com, qontak-customer-fe, chat stack (Appendix A) and benchmarked vs Zoho Zia / HubSpot / Salesforce / Gainsight (Appendix B). MVP decisions: CSAT-only satisfaction (NPS deferred, OQ-1); commerce metrics proposal-only (OQ-2); one active config/company.