cases: inactivity is not a decision, so a timer cannot close a case

AutoClose closed low-severity cases after thirty days of inactivity, writing
Status, Resolution and ClosedAt directly. Store.Resolve refuses a closure with no
assessment — a dismissed alert is a retained decision with its rationale, not a
deleted row — and this path wrote the same fields itself and walked around that
invariant. 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. That is the record AMLR Art. 77(1)(b) requires kept whether or not it
produced a report, and the question JMLSG 6.32 requires the compliance officer to
be able to answer: why was this not escalated.

Closing asserts that somebody assessed the case and decided. Inactivity asserts
the opposite. The two must not produce the same state, so the timer now escalates
instead — an alert nobody has looked at is exactly what a compliance officer needs
to see, and escalating puts it in front of one rather than deleting the question.

It also swept only low-severity cases, which reads as caution and is the wrong way
up: a critical case nobody has touched is the more serious governance failure. Every
severity is now in scope.

Nothing on a timer can close a case, and there is a test that says so rather than a
comment. Five mutants killed, including restoring the original close.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 15:54:48 -07:00
co-authored by Hanzo Dev
parent 3f540b7d8d
commit 58bc533040
2 changed files with 119 additions and 66 deletions
+53 -32
View File
@@ -11,8 +11,10 @@ import (
type TriageConfig struct {
AutoAssignEnabled bool
EscalationSLA map[string]time.Duration // severity -> max time before escalation
AutoCloseAfter time.Duration // auto-close low-severity if no activity
AssignmentMode string // "round_robin", "least_loaded", "manual"
// 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.
@@ -25,7 +27,7 @@ func DefaultTriageConfig() TriageConfig {
types.SeverityMedium: 24 * time.Hour,
types.SeverityLow: 72 * time.Hour,
},
AutoCloseAfter: 30 * 24 * time.Hour, // 30 days
StaleAfter: 30 * 24 * time.Hour, // 30 days unworked is a governance failure, not a decision
AssignmentMode: "round_robin",
}
}
@@ -118,16 +120,34 @@ func (s *Store) CheckEscalation(c *types.Case, config TriageConfig) bool {
return true
}
// AutoClose closes low-severity cases with no activity after the configured threshold.
// Returns the number of cases closed.
func (s *Store) AutoClose(config TriageConfig) int {
if config.AutoCloseAfter <= 0 {
// 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.
//
// 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(config TriageConfig) int {
if config.StaleAfter <= 0 {
return 0
}
now := time.Now().UTC()
threshold := now.Add(-config.AutoCloseAfter)
closed := 0
threshold := now.Add(-config.StaleAfter)
escalated := 0
s.mu.Lock()
defer s.mu.Unlock()
@@ -136,36 +156,37 @@ func (s *Store) AutoClose(config TriageConfig) int {
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
continue
}
if c.Severity != types.SeverityLow {
if !c.UpdatedAt.Before(threshold) {
continue
}
// Check last activity: use UpdatedAt as proxy.
if c.UpdatedAt.Before(threshold) {
c.Status = types.CaseClosed
c.Resolution = "auto_closed_inactive"
c.ClosedAt = &now
c.UpdatedAt = now
// 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
s.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
Kind: types.EventStatusChange,
CaseID: c.ID,
AuthorID: "system",
Body: "auto-closed: no activity for " + config.AutoCloseAfter.String(),
CreatedAt: now,
})
closed++
}
s.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
Kind: types.EventStatusChange,
CaseID: c.ID,
AuthorID: "system",
Body: "escalated: no one has worked this case for " + config.StaleAfter.String(),
CreatedAt: now,
})
escalated++
}
return closed
return escalated
}
// TriageCheck runs the full triage cycle: auto-assign unassigned, escalate overdue, auto-close stale.
// Intended to run every 5 minutes.
func (s *Store) TriageCheck(orgID string, analysts []string, config TriageConfig) (assigned, escalated, autoClosed int) {
// Auto-close stale low-severity FIRST — before assign refreshes UpdatedAt.
autoClosed = s.AutoClose(config)
// 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(config)
cases := s.List(orgID, "")
for _, c := range cases {
@@ -186,5 +207,5 @@ func (s *Store) TriageCheck(orgID string, analysts []string, config TriageConfig
}
}
return assigned, escalated, autoClosed
return assigned, escalated, stale
}
+66 -34
View File
@@ -105,52 +105,84 @@ func TestCheckEscalation_AlreadyClosed(t *testing.T) {
}
}
func TestAutoClose(t *testing.T) {
// A case nobody has worked must be escalated, never closed.
//
// Closing asserts that somebody assessed the case and decided; inactivity asserts
// the opposite, and the two must not produce the same state. Store.Resolve refuses
// a closure with no assessment for exactly this reason — a dismissed alert is a
// retained decision with its rationale (AMLR Art. 77(1)(b), JMLSG 6.32) — and the
// old auto-close path wrote the same fields directly and walked around it. Eviction
// then removed the closed case, so an alert that was raised and never reviewed left
// no trace of having existed.
func TestAStaleCaseIsEscalatedNotClosed(t *testing.T) {
s := NewStore()
// Low-severity case with old activity.
c := s.Create("org1", types.SeverityLow, nil, nil)
s.mu.Lock()
s.cases[c.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour) // 60 days old
s.mu.Unlock()
// Medium-severity case that should not be auto-closed.
c2 := s.Create("org1", types.SeverityMedium, nil, nil)
s.mu.Lock()
s.cases[c2.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour)
s.mu.Unlock()
config := DefaultTriageConfig()
closed := s.AutoClose(config)
if closed != 1 {
t.Errorf("closed = %d, want 1 (only low-severity)", closed)
stale := func(severity string) *types.Case {
c := s.Create("org1", severity, nil, nil)
s.mu.Lock()
s.cases[c.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour)
s.mu.Unlock()
return c
}
got := s.Get(c.ID)
if got.Status != types.CaseClosed {
t.Errorf("low-severity status = %q, want closed", got.Status)
}
if got.Resolution != "auto_closed_inactive" {
t.Errorf("resolution = %q, want auto_closed_inactive", got.Resolution)
low := stale(types.SeverityLow)
// A critical case nobody has touched is the more serious governance failure, not
// the less — the old path swept only low severity, which had it the wrong way up.
critical := stale(types.SeverityCritical)
if n := s.AutoEscalateStale(DefaultTriageConfig()); n != 2 {
t.Fatalf("escalated %d stale cases, want 2 — every severity, not only low", n)
}
got2 := s.Get(c2.ID)
if got2.Status == types.CaseClosed {
t.Error("medium-severity case should not be auto-closed")
for _, c := range []*types.Case{low, critical} {
got := s.Get(c.ID)
if got.Status == types.CaseClosed {
t.Errorf("a case nobody assessed was CLOSED, asserting a decision that was never made")
}
if got.Status != types.CaseEscalated {
t.Errorf("status = %q, want escalated", got.Status)
}
if got.Resolution != "" {
t.Errorf("resolution = %q — inactivity is not a resolution", got.Resolution)
}
if got.Assessment != "" {
t.Errorf("assessment = %q — no assessment was made", got.Assessment)
}
}
}
func TestAutoClose_RecentActivity(t *testing.T) {
// Nothing on a timer may close a case, whatever it is called. Closing requires an
// assessment and a person, and a cron has neither.
func TestNoTimerPathCanCloseACase(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityLow, nil, nil)
// UpdatedAt is now (fresh), so auto-close should skip it.
s.mu.Lock()
s.cases[c.ID].UpdatedAt = time.Now().UTC().Add(-365 * 24 * time.Hour)
s.mu.Unlock()
config := DefaultTriageConfig()
closed := s.AutoClose(config)
if closed != 0 {
t.Errorf("closed = %d, want 0 (recent activity)", closed)
s.AutoEscalateStale(DefaultTriageConfig())
s.TriageCheck("org1", []string{"analyst-1"}, DefaultTriageConfig())
if got := s.Get(c.ID); got.Status == types.CaseClosed {
t.Fatal("a triage cycle closed a case; only a person with an assessment may")
}
// And the closure path still refuses without one.
if err := s.Resolve(c.ID, "cleared", "analyst-1", ""); err == nil {
t.Fatal("a case was closed with no retained assessment")
}
}
// A case somebody worked recently is left alone.
func TestRecentActivityIsNotStale(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityLow, nil, nil)
if n := s.AutoEscalateStale(DefaultTriageConfig()); n != 0 {
t.Errorf("escalated %d cases, want 0 — this one was just created", n)
}
if got := s.Get(c.ID); got.Status != types.CaseOpen {
t.Errorf("status = %q, want open", got.Status)
}
_ = c
}
func TestTriageCheck_FullCycle(t *testing.T) {