Wiki · Substrate piece

Customer health

Leading-indicator churn prediction. AR aging tells you a customer disengaged 60 days ago. Customer health tells you they're about to. Seven signals roll up to a 0-100 score that bands green / yellow / orange / red.

Real · R557 Phase 53
What this is

Predict churn 30-60 days earlier than AR can

AR aging is a lagging indicator. By the time an invoice ages 30 days past due, the customer has already cooled off — placed fewer orders, stopped responding to emails, narrowed their SKU mix. The damage is done; we're just measuring it.

Customer health is the leading-indicator substrate. It watches seven behavioral signals continuously, normalizes each to a 0-1 risk contribution, and rolls them up to a per-customer 0-100 composite score. The score lands in customer_health_scores with a risk_tier band (green/yellow/orange/red), updated nightly with incremental refreshes off the event ledger.

The motivation comes from Mike's high-leverage moves doc — Driscoll is 36.4% of revenue. A single early warning on a top-5 customer pays the entire substrate cost back 100x. Schema lives in migrations/schema/126_customer_health_signals.sql, landed R557 / Phase 53.

The seven signals

What we watch

SignalWhat it measuresTypical weight
order_velocity_declineOrder count last-30 vs trailing-90 baselineHigh
order_recency_gapDays since last order vs typical interval (median of last-12)High
email_response_latencyMean reply time (us → them) trend over last-90Medium
dispute_frequencyDispute / credit-memo count last-30Medium
inbound_sentiment_trendAI-classified email sentiment slopeMedium
order_spread_narrowingDistinct SKU count vs typical mixMedium
call_to_order_ratioInbound calls without orders (when call data exists)Low
reflexion_frictionCount of reflexion entries tagged 'friction' for this customerLow
ar_aging_laggingThe lagging signal — kept for completenessLow (it's confirmation, not prediction)
Why these signals

They're behavioral, not financial. Behavioral data leads financial data by weeks. A customer that's narrowing their order spread is sampling alternatives; a customer that's responding slower has another vendor's email above ours in their inbox. Watch behavior; the dollars follow.

Tier mapping

0-100 score → risk tier

TierScore rangeWhat it meansSuggested action
Green0–25Healthy, engaged customerMonitor, opportunities for upsell
Yellow26–50Some warning signals; watch listDaily digest mention
Orange51–75Multiple signals tripping; intervention warrantedMike personal touch within 2 weeks
Red76–100Imminent churn riskSame-day phone call; escalate to win-back workflow

Lower score = healthier. The tier bands are calibrated against the R567 backtest — a sample of 28 customers who proxy-churned (silent zero-revenue for 90+ days). The substrate would have flagged 21 of those 28 in orange or red tier ≥ 30 days before the AR aging caught it.

Backtest results

R567 — 28 proxy-churned customers

Sample size
28
proxy-churned customers
Detected early
21/28
75% recall
Median lead time
42 days
before AR caught it
False positives
~12%
flagged red but recovered

Top contributing signals across the 21 detections, in order:

The 7 missed cases were customers whose churn was driven by external factors (acquisition, closure, contract loss) that didn't show up in behavioral signals. We can't predict those from our data alone.

Worked example

How a yellow customer moves to orange

Scenario

Magnolia Beef Co (NS customer #3104), trailing-90 weekly orders. On May 1, score was 28 (yellow band). Through May the signals shift: order_velocity_decline rises (4 orders this week vs typical 7), order_recency_gap trips (last order 11 days ago, median interval 5), email_response_latency doubled (4-day mean vs 2-day baseline).

Nightly recompute on May 24 pushes the score to 58 — orange band. The daily digest at 7am May 25 leads with Magnolia. Mike calls the AP contact, learns they're piloting a competitor on chicken. Mike pivots — offers a sample shipment of two assemblies, schedules a follow-up call. Score stays orange for now; if the intervention works, it'll drift back to yellow over 30 days.

Step-by-step recompute

How a score gets built

  1. 01

    Signal compute (per signal)

    Each signal has a compute function in src/lib/customer_health/signals/. They run nightly (full) and incrementally via event subscriptions (partial). Each write to customer_health_signals with normalized value in [0,1].

    Writes customer_health_signals
    Time ~10ms/customer/signal
  2. 02

    Composite score

    Weighted sum over the latest signal values per customer. Weights live in customer_health_weights (configurable). Result: a 0-100 score and the top-3 contributing signals.

    Writes customer_health_scores
  3. 03

    Tier band + trend

    Score is banded into green/yellow/orange/red. Prior score is read; trend_arrow is set (up/down/flat based on delta > 5 points). History row appended to customer_health_score_history.

    Writes customer_health_scores, customer_health_score_history
  4. 04

    Event emission on tier change

    If tier changed (e.g., yellow → orange), emit customer.health_tier_changed to the events ledger. Downstream: digest digester picks it up, AR aging workflow re-prioritizes, sales follow-up may auto-create.

    Emits customer.health_tier_changed
  5. 05

    Daily digest

    The 7am ET digest reads top-N customers by score and any tier changes overnight. Mike's morning email leads with new oranges/reds.

    Reads customer_health_scores
Outcomes

What's different with this substrate

Failure modes

What can go wrong

Cold start (new customer)

New customers don't have enough history for several signals. They start in yellow until 90 days of behavior accumulates. Notes column flags this so Mike doesn't read too much into early scores.

Seasonal signal noise

K-12 customers go quiet over summer — that's expected, not churn. The order_recency_gap signal has a school-calendar-aware baseline; backtest excludes Jun-Aug for school customers.

External churn drivers

7/28 backtest misses were customers we couldn't have predicted from internal data. Adding industry-news ingestion is a future Phase 5x item.

Related

Adjacent substrate + workflows

For developers

Code paths + invariants

ConcernWhere
Schemamigrations/schema/126_customer_health_signals.sql
Signal computessrc/lib/customer_health/signals/
Compositesrc/lib/customer_health/composite.ts
BacktestR567 — eval/customer-health-backtest.yaml
Weightscustomer_health_weights table — configurable
Cronwrangler.toml — nightly + */5 min incremental
Cold-start rule< 90 days history → yellow with note
Tier change eventcustomer.health_tier_changed
Changelog

Dated trail · spot stale claims

Dated trail of when this doc was last touched, what changed, and what to look at if it feels stale.

DateRoundChangeTouched by
2026-05-26R586Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation.Mike + Claude
2026-05-25R584/R585Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers).Mike + Claude
If today is more than 60 days past the latest changelog row, treat live system behavior as the source of truth. The doc may have drifted — verify against the workflow contract in workflow_definitions WHERE workflow_type='customer_health_score' before acting on these claims.
Schema · data contract

The machine-readable spec

Canonical fields, table names, endpoint signatures. What code should match, what tests should assert. workflow_type · customer_health_score · risk_level · N/A (substrate read-model).

Inputs (required + optional)

FieldTypeDescription
customer_idintegerTarget customer. Required.
as_of_datedateSnapshot timestamp; defaults to today.

D1 tables written

TableOperationTrigger
customer_health_snapshotsINSERT (one per recompute)Score + breakdown
customer_health_signalsINSERT (one per signal)What contributed to the score
eventsINSERT (health.changed)If score crossed a band boundary

Endpoints called

MethodPathPurpose
GET/api/customer-health/:idLatest snapshot
POST/api/customer-health/:id/recomputeManual recompute trigger
GET/customer-health.htmlCohort dashboard

Events fired

event_typeWhenSubscribers
health.changedWhen score crosses a band boundaryar_aging, service_hold suggestion
health.recomputedEvery recompute, even if score unchangedaudit only
Runbook · when it breaks

It broke at 2am — what now

Different from "how do I use this." This is the page Mike pulls up when something is wrong: logs to check, recovery steps, who to escalate to.

Scenario · Score went red for a customer Mike thinks is healthy

Inspect the signals. Often it's one bad signal dragging the composite.

  1. Pull signals: SELECT * FROM customer_health_signals WHERE customer_id=<id> AND snapshot_id=(SELECT MAX(id) FROM customer_health_snapshots WHERE customer_id=?)
  2. Inspect weights: Compare each signal's weight × score to find the outlier.
  3. Override: Set customer_overrides.health_band='green' with a reason; sticks until removed.

Scenario · Score never updated despite obvious change (lost a big PO)

Recompute cron may be stuck or the event didn't trigger a recompute.

  1. Check cron: SELECT MAX(created_at) FROM customer_health_snapshots WHERE customer_id=<id>
  2. Force recompute: POST /api/customer-health/<id>/recompute
  3. Investigate cron lock: SELECT * FROM cron_locks WHERE cron_name='customer_health' — stuck lock blocks future runs.

Scenario · Health snapshot shows 0 across all signals

Sync probably stale — underlying data wasn't there at recompute time.

  1. Sync freshness: SELECT MAX(completed_at) FROM sync_log WHERE sync_type LIKE '%customer%' — must be recent.
  2. Force sync: POST /api/sync/run?tier=hot
  3. Recompute: After sync, recompute health.

Logs to check

Kill switch · emergency stop

If this workflow is misbehaving in a high-impact way (creating bad proposed_actions in volume, pushing wrong things to NS), flip a kill switch:

See kill-switches-state-machine.html for the full state machine + recovery procedure.

Escalation

Primary: Mike Levine (single-admin) · mikelevine@globalfoodsolutions.co. For prolonged outage during business hours, notify warehouse lead + accounting lead so they can defer dependent work.

Backlog · open questions

What's not done · what's uncertain

What's not done, what's uncertain, what we punted. Captured so it survives context switches and doesn't die in someone's head.