2.0 KiB
2.0 KiB
type, status, tags, updated
| type | status | tags | updated | ||||
|---|---|---|---|---|---|---|---|
| decision | shipped |
|
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:
- Roll-off amount per cycle: 5 points, oldest first
- Repeat cadence: another full 90 clean days required for each successive 5-point roll-off
- 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_violationand 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/expirationresponse shape changed (was per-violation list, now{ schedule: [...] }). FrontendExpirationTimeline.jsxupdated to match — any third-party consumer of that endpoint would break.- Existing
prior_active_pointssnapshots on pre-fix violation rows are stale; existing "Backfill Snapshots" admin button recomputes them under the new model on demand. recomputeSnapshotsAfterno 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.