# Score decay for inactive contacts

- **Pattern:** `ab-000617` (`campaign-orchestration-sequencing.lead-scoring.score-decay`)
- **Severity:** low
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000617
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000617)

## 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).

```ts
// 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_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:

  ```ts
  // 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 functional-suitability.functional-correctness

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-000617
