Score decay for inactive contacts
Why it matters
Without score decay, a contact who was highly engaged 18 months ago — opened five emails, clicked twice, visited the pricing page — retains a high score and continues surfacing as a hot lead. Sales receives contacts who have not interacted in over a year, the pipeline confidence drops, and the scoring model loses calibration. iso-25010:2011 functional-suitability.functional-correctness categorizes this as an accuracy defect: the score no longer reflects the contact's current engagement probability. Score decay is also an anti-sycophancy mechanism in the scoring model itself — it resists inflating lead quality based on historical activity that no longer predicts current intent.
Severity rationale
Low because missing decay accumulates stale high scores over months — a gradual calibration failure that degrades sales pipeline quality, not an immediate runtime error.
Remediation
Add a last_activity_at timestamp to contact records and run a periodic decay job (daily via Vercel cron or a scheduled BullMQ repeatable job).
// Runs daily via cron — e.g. src/jobs/score-decay.ts
async function applyScoreDecay() {
const DECAY_PERCENT = 10 // Reduce score by 10% per inactivity period
const INACTIVITY_DAYS = 30 // Apply after 30 days of no activity
const cutoff = new Date(Date.now() - INACTIVITY_DAYS * 24 * 60 * 60 * 1000)
await db.contact.updateMany({
where: {
lastActivityAt: { lt: cutoff },
leadScore: { gt: 0 }
},
data: {
leadScore: { multiply: (100 - DECAY_PERCENT) / 100 }
}
})
}
Both DECAY_PERCENT and INACTIVITY_DAYS should be named constants (not magic numbers) and referenced from your central scoring config. A last_activity_at field with no decay job referencing it does not satisfy this check.
Detection
-
ID:
score-decay -
Severity:
low -
What to look for: Check whether lead scores decay over time for contacts who stop engaging. Without decay, a contact who was active 18 months ago but has since gone silent retains a high score and may receive inappropriate sales attention. Look for: a scheduled job that reduces scores for contacts with no recent engagement (e.g., reduce score by 10% every 30 days of inactivity), a
last_activity_atfield on contact records, or a decay factor in the scoring model. -
Pass criteria: A score decay mechanism is implemented — either time-based reduction or a decay factor applied during score evaluation. Inactive contacts' scores decrease over time. Count the decay parameters (rate, threshold, frequency) and verify at least 2 are configurable (e.g., decay percentage and inactivity window).
-
Fail criteria: No score decay. Scores only ever increase. Old high-scoring contacts retain their scores indefinitely.
-
Skip (N/A) when: The project does not implement lead scoring, or the sales cycle is short enough that decay is irrelevant.
-
Detail on fail:
"No score decay mechanism — scores accumulate indefinitely, never decreasing for inactive contacts"or"last_activity_at field exists but no decay job references it" -
Remediation: Add a periodic decay job:
// Runs daily via cron async function applyScoreDecay() { const DECAY_PERCENT = 10 // Decay 10% per 30-day inactivity period const INACTIVITY_THRESHOLD_DAYS = 30 const cutoff = new Date(Date.now() - INACTIVITY_THRESHOLD_DAYS * 24 * 60 * 60 * 1000) await db.contact.updateMany({ where: { lastActivityAt: { lt: cutoff }, leadScore: { gt: 0 } }, data: { leadScore: { multiply: (100 - DECAY_PERCENT) / 100 } } }) }
External references
- iso-25010:2011 · functional-suitability.functional-correctness
Taxons
History
- 2026-04-18·v1.0.0·Initial import from campaign-orchestration-sequencing·automated