--- type: decision status: shipped tags: - cpas - scoring - roll-off - mpm updated: 2026-05-27 --- # [[CPAS]] roll-off: clean-cycle model, JS as source of truth ## Context System was implementing a literal per-violation rolling 90-day window. Jason reported that the handbook actually says "5 points roll off after 90 consecutive days of no violations" — i.e. any new violation should reset the countdown for everyone, not just for itself. ## Decision Adopt the clean-cycle model: 1. Roll-off amount per cycle: **5 points, oldest first** 2. Repeat cadence: **another full 90 clean days required** for each successive 5-point roll-off 3. Reset trigger: **any new non-negated violation** (negated violations don't reset) Implement all standing math in JS (`lib/rolloff.js` `computeStanding()`). Drop the `active_cpas_scores` SQL view — oldest-first partial allocation isn't cleanly SQL-expressible, and having both a SQL and JS implementation invites divergence. ## Alternatives considered - Keeping the SQL view with the aggregate formula `total - 5*cycles_since_last_violation` and computing per-violation breakdown only in JS. Rejected: two implementations of the same rule = drift risk; dashboard scale at MPM is small enough that per-request JS computation is fine. - "All points roll off at once after 90 clean days" or "oldest entire violation rolls off." Rejected by Jason in clarifying questions. ## Consequences - `/api/employees/:id/expiration` response shape changed (was per-violation list, now `{ schedule: [...] }`). Frontend `ExpirationTimeline.jsx` updated to match — any third-party consumer of that endpoint would break. - Existing `prior_active_points` snapshots on pre-fix violation rows are stale; existing "Backfill Snapshots" admin button recomputes them under the new model on demand. - `recomputeSnapshotsAfter` no longer caps the affected window at +90 days — backdated inserts now scan all later violations, since under the new model an earlier total shift propagates indefinitely.