mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
The case store evicted. Ninety days after a case was closed it was dropped, with its timeline, once the store held more than a hundred thousand of them — and both the count and the sweep were taken across every tenant at once, so one institution's volume destroyed another institution's evidence, and neither of them was told. A case is the record that an alert was considered and what was decided, kept whichever way the decision went (AMLR Art. 77(1)(b); JMLSG 6.32), for five years (Dir. (EU) 2015/849 Art. 40). This repo already has a plane that keeps that clock: pkg/retention counts in years, per tenant, refuses a date its own clock has not reached, and proves what it destroyed before it reports a count. A second clock here, counting in days, could not be reconciled with it — whichever was shorter was the real retention period, and nobody wrote that one down. So the durable shelf has no bound, and no constructor that could give it one. What eviction remains is the memory shelf's, only this package's tests build it, and it is scoped to one org. The sweep is scoped at the seam rather than at its callers: shelf.each now names the org whose cases it visits, so counting, triage and eviction are each a tenant's own by construction. That closed two more crossings of the same boundary that were not eviction at all — the stale-case sweep escalated every institution's cases on any tenant's cycle, which is a write on another institution's investigation, and least-loaded assignment counted another institution's queue into this one's analyst load. Case numbering is per tenant too: drawn from one sequence for the deployment, the gaps in one institution's file references counted another's cases. Opening a case now takes no census. The count that decided whether to evict was a full scan of every case in the store, under the store lock, on every Create — the path the replay gate calls the request that must not wait — and once the store was over the threshold with nothing old enough to drop, every Create paid two of those scans forever. pkg/cases/evict_test.go is what says so, and each of these tests was checked against the defect it describes: reinstating the bound fails the two durable tests, un-scoping the sweeps fails the four tenant tests, and restoring the census fails the ingest one. Co-authored-by: Hanzo Dev <[email protected]>
229 lines
6.7 KiB
Go
229 lines
6.7 KiB
Go
package cases
|
|
|
|
import (
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/luxfi/aml/pkg/types"
|
|
)
|
|
|
|
// TriageConfig controls auto-assignment, escalation SLAs, and auto-close behavior.
|
|
type TriageConfig struct {
|
|
AutoAssignEnabled bool
|
|
EscalationSLA map[string]time.Duration // severity -> max time before escalation
|
|
// StaleAfter is how long a case may sit unworked before it is escalated. A
|
|
// case is never closed on a timer: closing asserts a decision was made.
|
|
StaleAfter time.Duration
|
|
AssignmentMode string // "round_robin", "least_loaded", "manual"
|
|
}
|
|
|
|
// DefaultTriageConfig returns production-safe SLA defaults.
|
|
func DefaultTriageConfig() TriageConfig {
|
|
return TriageConfig{
|
|
AutoAssignEnabled: true,
|
|
EscalationSLA: map[string]time.Duration{
|
|
types.SeverityCritical: 1 * time.Hour,
|
|
types.SeverityHigh: 4 * time.Hour,
|
|
types.SeverityMedium: 24 * time.Hour,
|
|
types.SeverityLow: 72 * time.Hour,
|
|
},
|
|
StaleAfter: 30 * 24 * time.Hour, // 30 days unworked is a governance failure, not a decision
|
|
AssignmentMode: "round_robin",
|
|
}
|
|
}
|
|
|
|
// rrIndex tracks round-robin state for assignment.
|
|
var rrIndex atomic.Int64
|
|
|
|
// AutoAssign assigns an unassigned case to an analyst.
|
|
// Returns the assigned analyst ID, or empty if no analysts available.
|
|
func (s *Store) AutoAssign(c *types.Case, analysts []string, mode string) string {
|
|
if len(analysts) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var assignee string
|
|
|
|
switch mode {
|
|
case "round_robin":
|
|
idx := rrIndex.Add(1) - 1
|
|
assignee = analysts[idx%int64(len(analysts))]
|
|
|
|
case "least_loaded":
|
|
// Count open cases per analyst, within the institution the case belongs
|
|
// to. An analyst's load is what THIS tenant has given them; counting
|
|
// another institution's queue into it both leaks that queue's size and
|
|
// assigns on a number that is not about this one.
|
|
loads := make(map[string]int)
|
|
_ = s.shelf.each(c.OrgID, func(existing *types.Case) error {
|
|
if existing.Status != types.CaseClosed && existing.AssigneeID != "" {
|
|
loads[existing.AssigneeID]++
|
|
}
|
|
return nil
|
|
})
|
|
|
|
minLoad := -1
|
|
for _, a := range analysts {
|
|
load := loads[a]
|
|
if minLoad == -1 || load < minLoad {
|
|
minLoad = load
|
|
assignee = a
|
|
}
|
|
}
|
|
|
|
default: // "manual" or unknown: no auto-assign
|
|
return ""
|
|
}
|
|
|
|
if assignee != "" {
|
|
_ = s.Assign(c.ID, assignee)
|
|
}
|
|
|
|
return assignee
|
|
}
|
|
|
|
// CheckEscalation returns true and escalates the case if the SLA is breached.
|
|
func (s *Store) CheckEscalation(c *types.Case, config TriageConfig) bool {
|
|
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
|
|
return false
|
|
}
|
|
|
|
sla, ok := config.EscalationSLA[c.Severity]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
deadline := c.OpenedAt.Add(sla)
|
|
if time.Now().UTC().Before(deadline) {
|
|
return false
|
|
}
|
|
|
|
// SLA breached: escalate.
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
existing, err := s.shelf.get(c.ID)
|
|
if err != nil || existing == nil {
|
|
return false
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
existing.Status = types.CaseEscalated
|
|
existing.UpdatedAt = now
|
|
if err := s.shelf.put(existing); err != nil {
|
|
return false
|
|
}
|
|
|
|
_ = s.shelf.appendEvent(types.CaseEvent{
|
|
ID: uuid.NewString(),
|
|
Kind: types.EventStatusChange,
|
|
CaseID: c.ID,
|
|
AuthorID: "system",
|
|
Body: "auto-escalated: SLA breached",
|
|
CreatedAt: now,
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
// AutoEscalateStale escalates cases nobody has worked after the configured
|
|
// threshold, and returns how many it escalated.
|
|
//
|
|
// It used to CLOSE them, which is the defect this replaces. Closing a case asserts
|
|
// that somebody assessed it and decided; inactivity asserts the opposite. The two
|
|
// must not produce the same state, because a dismissed alert is a retained decision
|
|
// with its rationale — Regulation (EU) 2024/1624 Art. 77(1)(b) requires the record
|
|
// of the assessment whether or not it resulted in a report, and JMLSG 6.32 requires
|
|
// the compliance officer to consider why alerts were not escalated. "Nobody looked
|
|
// at it for thirty days" is not an assessment anybody can defend.
|
|
//
|
|
// It was also unreachable as a control. Store.Resolve refuses a closure with no
|
|
// assessment; this path wrote the same fields directly and walked around that
|
|
// invariant, and eviction then removed the closed case after its retention window —
|
|
// so an alert that was raised, never reviewed, and silently closed left no trace of
|
|
// having existed.
|
|
//
|
|
// It sweeps ONE org. Unscoped it escalated every institution's cases on any
|
|
// tenant's cycle, which is a cross-tenant write on the record of another
|
|
// institution's investigation.
|
|
//
|
|
// Escalation is the honest outcome: an alert nobody has looked at is exactly what a
|
|
// compliance officer needs to see, and escalating puts it in front of one instead of
|
|
// deleting the question.
|
|
func (s *Store) AutoEscalateStale(orgID string, config TriageConfig) int {
|
|
if config.StaleAfter <= 0 {
|
|
return 0
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
threshold := now.Add(-config.StaleAfter)
|
|
escalated := 0
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
_ = s.shelf.each(orgID, func(c *types.Case) error {
|
|
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
|
|
return nil
|
|
}
|
|
if !c.UpdatedAt.Before(threshold) {
|
|
return nil
|
|
}
|
|
|
|
// Every severity, not only low. The original only swept low-severity cases,
|
|
// which reads as caution and is the wrong way round: a critical case nobody
|
|
// has touched is the more serious governance failure, not the less.
|
|
c.Status = types.CaseEscalated
|
|
c.UpdatedAt = now
|
|
if err := s.shelf.put(c); err != nil {
|
|
return nil
|
|
}
|
|
|
|
_ = s.shelf.appendEvent(types.CaseEvent{
|
|
ID: uuid.NewString(),
|
|
Kind: types.EventStatusChange,
|
|
CaseID: c.ID,
|
|
AuthorID: "system",
|
|
Body: "escalated: no one has worked this case for " + config.StaleAfter.String(),
|
|
CreatedAt: now,
|
|
})
|
|
escalated++
|
|
return nil
|
|
})
|
|
|
|
return escalated
|
|
}
|
|
|
|
// TriageCheck runs the full triage cycle: escalate cases nobody has worked, assign
|
|
// the unassigned, escalate those past their SLA. Intended to run every few minutes.
|
|
//
|
|
// Nothing here closes a case. Closing requires an assessment and a person, and
|
|
// neither is available to a cron.
|
|
func (s *Store) TriageCheck(orgID string, analysts []string, config TriageConfig) (assigned, escalated, stale int) {
|
|
// Stale first, before assignment refreshes UpdatedAt and hides the staleness.
|
|
stale = s.AutoEscalateStale(orgID, config)
|
|
|
|
cases := s.List(orgID, "")
|
|
for _, c := range cases {
|
|
if c.Status == types.CaseClosed {
|
|
continue
|
|
}
|
|
|
|
// Auto-assign unassigned cases.
|
|
if config.AutoAssignEnabled && c.AssigneeID == "" {
|
|
if a := s.AutoAssign(c, analysts, config.AssignmentMode); a != "" {
|
|
assigned++
|
|
}
|
|
}
|
|
|
|
// Check escalation.
|
|
if s.CheckEscalation(c, config) {
|
|
escalated++
|
|
}
|
|
}
|
|
|
|
return assigned, escalated, stale
|
|
}
|