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.
What we watch
| Signal | What it measures | Typical weight |
|---|---|---|
order_velocity_decline | Order count last-30 vs trailing-90 baseline | High |
order_recency_gap | Days since last order vs typical interval (median of last-12) | High |
email_response_latency | Mean reply time (us → them) trend over last-90 | Medium |
dispute_frequency | Dispute / credit-memo count last-30 | Medium |
inbound_sentiment_trend | AI-classified email sentiment slope | Medium |
order_spread_narrowing | Distinct SKU count vs typical mix | Medium |
call_to_order_ratio | Inbound calls without orders (when call data exists) | Low |
reflexion_friction | Count of reflexion entries tagged 'friction' for this customer | Low |
ar_aging_lagging | The lagging signal — kept for completeness | Low (it's confirmation, not prediction) |
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.
0-100 score → risk tier
| Tier | Score range | What it means | Suggested action |
|---|---|---|---|
| Green | 0–25 | Healthy, engaged customer | Monitor, opportunities for upsell |
| Yellow | 26–50 | Some warning signals; watch list | Daily digest mention |
| Orange | 51–75 | Multiple signals tripping; intervention warranted | Mike personal touch within 2 weeks |
| Red | 76–100 | Imminent churn risk | Same-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.
R567 — 28 proxy-churned customers
Top contributing signals across the 21 detections, in order:
- order_velocity_decline — present in 19/21 detections (90%).
- order_recency_gap — present in 17/21 (81%).
- email_response_latency — present in 14/21 (67%).
- order_spread_narrowing — present in 11/21 (52%) and a strong precursor when paired with the top two.
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.
How a yellow customer moves to orange
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.
How a score gets built
-
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 tocustomer_health_signalswith normalized value in [0,1]. -
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. -
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. -
04
Event emission on tier change
If tier changed (e.g., yellow → orange), emit
customer.health_tier_changedto the events ledger. Downstream: digest digester picks it up, AR aging workflow re-prioritizes, sales follow-up may auto-create. -
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.
What's different with this substrate
- Mike sees deteriorating customers 30-60 days before AR catches them on average.
- Top-revenue accounts (Driscoll = 36.4%) get continuous behavioral monitoring, not periodic checks.
- Customer 360 in chat surfaces the health score and top-3 signals so any tool call has it as context.
- Customer quote and bid price workflows reference health for soft guardrails.
- Backtest recall: 75%. Continued tuning improves it as signal weights are refined.
What can go wrong
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.
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.
7/28 backtest misses were customers we couldn't have predicted from internal data. Adding industry-news ingestion is a future Phase 5x item.
Adjacent substrate + workflows
Code paths + invariants
| Concern | Where |
|---|---|
| Schema | migrations/schema/126_customer_health_signals.sql |
| Signal computes | src/lib/customer_health/signals/ |
| Composite | src/lib/customer_health/composite.ts |
| Backtest | R567 — eval/customer-health-backtest.yaml |
| Weights | customer_health_weights table — configurable |
| Cron | wrangler.toml — nightly + */5 min incremental |
| Cold-start rule | < 90 days history → yellow with note |
| Tier change event | customer.health_tier_changed |
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.
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R586 | Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation. | Mike + Claude |
2026-05-25 | R584/R585 | Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers). | Mike + Claude |
workflow_definitions WHERE workflow_type='customer_health_score' before acting on these claims.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)
| Field | Type | Description |
|---|---|---|
customer_id | integer | Target customer. Required. |
as_of_date | date | Snapshot timestamp; defaults to today. |
D1 tables written
| Table | Operation | Trigger |
|---|---|---|
customer_health_snapshots | INSERT (one per recompute) | Score + breakdown |
customer_health_signals | INSERT (one per signal) | What contributed to the score |
events | INSERT (health.changed) | If score crossed a band boundary |
Endpoints called
| Method | Path | Purpose |
|---|---|---|
GET | /api/customer-health/:id | Latest snapshot |
POST | /api/customer-health/:id/recompute | Manual recompute trigger |
GET | /customer-health.html | Cohort dashboard |
Events fired
| event_type | When | Subscribers |
|---|---|---|
health.changed | When score crosses a band boundary | ar_aging, service_hold suggestion |
health.recomputed | Every recompute, even if score unchanged | audit only |
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.
- Pull signals:
SELECT * FROM customer_health_signals WHERE customer_id=<id> AND snapshot_id=(SELECT MAX(id) FROM customer_health_snapshots WHERE customer_id=?) - Inspect weights: Compare each signal's weight × score to find the outlier.
- 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.
- Check cron:
SELECT MAX(created_at) FROM customer_health_snapshots WHERE customer_id=<id> - Force recompute:
POST /api/customer-health/<id>/recompute - 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.
- Sync freshness:
SELECT MAX(completed_at) FROM sync_log WHERE sync_type LIKE '%customer%'— must be recent. - Force sync:
POST /api/sync/run?tier=hot - Recompute: After sync, recompute health.
Logs to check
workflow_run_log· top-level run auditworkflow_step_log· per-step traceworkflow_verify_results· post-window verify outcomescron_locks· stuck cron lock detectionevents· workflow.completed / workflow.failed event trailreflexion_log· per-run narrative (if reflexion_enabled)npx wrangler tail· live Worker logs
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:
kill:ns_writes· stops every NS push platform-widekill:proposed_apply· stops HITL approvals from executing fan-outkill:high_risk_ops· stops risk_level >= 4 fan-out
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.
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.
-
OPEN
Predictive model — churn likelihood
Today health is rules-based. Could train a churn model on historical drops + churn outcomes. Needs labeled churn data first.
-
DEFER
Customer-facing score visibility
Showing customers their health score would be aggressive. Internal only for now.
-
STUB
Signal-level explanation in chat
When Mike asks 'why is Customer X yellow' the answer is currently 'check the signals' — not narrated.
-
DECISION
Recompute frequency vs cost
Hourly recompute for 500 customers = 12K computes/day. Could throttle to daily for inactive customers.