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.
Low because missing decay accumulates stale high scores over months — a gradual calibration failure that degrades sales pipeline quality, not an immediate runtime error.
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.
ID: campaign-orchestration-sequencing.lead-scoring.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_at field 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 }
}
})
}