mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
cases: the case plane is durable
The other half of the record. Cases and their timelines were two maps, so an investigation lasted until the pod did — and 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). A rollout took the evidence of the work with it: who looked, what they found, what they concluded. Split the state onto a shelf, the way the retention ledger already is: one interface, a memory implementation for the tests, and a Base-backed one for what an instance serves from. Two collections, aml_cases and aml_case_events, declared as store.Kinds so every read is tenant-scoped and every write carries an org. Three things the maps got for free and the shelf has to earn, so each is asserted: the case number continues across a restart rather than starting again at one, which is what stops two cases sharing a number; Get is deliberately unscoped because the caller holding the id is the one that knows whose it should be, and putting the check in two places makes the second one the easy one to forget; and eviction still only removes cases closed longer ago than the window, so an open investigation is never dropped to make room. TestCasesSurviveARestart takes the bytes as they are on disk, drops the instance, opens a second one over them and asks for the case, its note and its author — then checks the other tenant still cannot read or write it, that the next case is number two, and that a resolution sticks. Full suite green under -race. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+9
-1
@@ -157,6 +157,14 @@ func main() {
|
||||
}
|
||||
records := retention.NewBase(app)
|
||||
|
||||
// The case plane, on the same terms. A case is the record that an
|
||||
// alert was considered and what was decided (AMLR Art. 77(1)(b)),
|
||||
// and the timeline is the evidence of the work — both have to be
|
||||
// there after a restart, so both are Base-backed.
|
||||
if err := cases.Ensure(app); err != nil {
|
||||
return fmt.Errorf("refusing to start, the case plane cannot be created: %w", err)
|
||||
}
|
||||
|
||||
rates := reference.RatesFromEnv()
|
||||
|
||||
eng := engine.New(engine.Providers{
|
||||
@@ -192,7 +200,7 @@ func main() {
|
||||
// assumption, and it qualifies its tenant the same way.
|
||||
Identity: api.IAMIdentity(api.JWKS(keysTTL, keysStale), client),
|
||||
Engine: eng,
|
||||
Cases: cases.NewStore(),
|
||||
Cases: cases.NewBase(app),
|
||||
Alerts: api.NewAlertStore(),
|
||||
Screen: lists,
|
||||
Readiness: readiness,
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cases
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/hanzoai/dbx"
|
||||
|
||||
"github.com/luxfi/aml/pkg/store"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// Field names, declared once because the writer and the reader have to agree. A
|
||||
// disagreement between them does not fail — it returns nothing, and a case list
|
||||
// that returns nothing looks exactly like an institution with no open cases.
|
||||
const (
|
||||
fieldCase = "case_id"
|
||||
fieldNumber = "number"
|
||||
fieldStatus = "status"
|
||||
fieldSeverity = "severity"
|
||||
fieldEntities = "entity_ids"
|
||||
fieldAlerts = "alert_ids"
|
||||
fieldAssignee = "assignee"
|
||||
fieldOpened = "opened"
|
||||
fieldClosed = "closed"
|
||||
fieldResolution = "resolution"
|
||||
fieldAssessment = "assessment"
|
||||
fieldCreated = "created_at"
|
||||
fieldUpdated = "updated_at"
|
||||
|
||||
// On the timeline only.
|
||||
fieldEvent = "event_id"
|
||||
fieldAuthor = "author"
|
||||
fieldKind = "kind"
|
||||
fieldBody = "body"
|
||||
fieldFile = "file_path"
|
||||
fieldAt = "at"
|
||||
)
|
||||
|
||||
// caseKind is where cases live.
|
||||
var caseKind = store.Kind{
|
||||
Name: "aml_cases",
|
||||
Fields: []core.Field{
|
||||
&core.TextField{Name: fieldCase, Required: true},
|
||||
&core.NumberField{Name: fieldNumber, Required: true},
|
||||
&core.TextField{Name: fieldStatus, Required: true},
|
||||
&core.TextField{Name: fieldSeverity},
|
||||
&core.JSONField{Name: fieldEntities},
|
||||
&core.JSONField{Name: fieldAlerts},
|
||||
&core.TextField{Name: fieldAssignee},
|
||||
&core.DateField{Name: fieldOpened, Required: true},
|
||||
&core.DateField{Name: fieldClosed},
|
||||
&core.TextField{Name: fieldResolution},
|
||||
&core.TextField{Name: fieldAssessment},
|
||||
&core.DateField{Name: fieldCreated, Required: true},
|
||||
&core.DateField{Name: fieldUpdated, Required: true},
|
||||
},
|
||||
Indexes: []store.Index{
|
||||
// A case is reached by its id, and an id is unique within the tenant that
|
||||
// owns it. Unique because writing the same case twice is a bug, not a
|
||||
// second case.
|
||||
{Name: "case", Fields: []string{store.Org, fieldCase}, Unique: true},
|
||||
// The queue: an org's cases, by status.
|
||||
{Name: "queue", Fields: []string{store.Org, fieldStatus, fieldNumber}},
|
||||
// The next case number, and eviction's cutoff.
|
||||
{Name: "closed", Fields: []string{fieldClosed}},
|
||||
},
|
||||
}
|
||||
|
||||
// eventKind is the case timeline. It is a collection of its own because a case
|
||||
// has many events and a value that repeats cannot be a column.
|
||||
var eventKind = store.Kind{
|
||||
Name: "aml_case_events",
|
||||
Fields: []core.Field{
|
||||
&core.TextField{Name: fieldCase, Required: true},
|
||||
&core.TextField{Name: fieldEvent, Required: true},
|
||||
&core.TextField{Name: fieldAuthor},
|
||||
&core.TextField{Name: fieldKind, Required: true},
|
||||
&core.TextField{Name: fieldBody},
|
||||
&core.TextField{Name: fieldFile},
|
||||
&core.DateField{Name: fieldAt, Required: true},
|
||||
},
|
||||
Indexes: []store.Index{
|
||||
// The timeline, oldest first.
|
||||
{Name: "timeline", Fields: []string{fieldCase, fieldAt}},
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure creates what cases are kept in. It is idempotent, so it runs on every
|
||||
// start, and it has to have run before the first case is opened.
|
||||
func Ensure(app core.App) error {
|
||||
if err := caseKind.Ensure(app); err != nil {
|
||||
return err
|
||||
}
|
||||
return eventKind.Ensure(app)
|
||||
}
|
||||
|
||||
// durable keeps cases in Base collections, which survive a restart.
|
||||
type durable struct{ app core.App }
|
||||
|
||||
// NewBase returns a case store that survives a restart. [Ensure] has to have run
|
||||
// first.
|
||||
//
|
||||
// This is what an instance serves from. A case is the record that an alert was
|
||||
// considered and what was decided (AMLR Art. 77(1)(b); JMLSG 6.32), and a record
|
||||
// that a rollout empties is not a record — the question it answers comes back
|
||||
// unanswerable at exactly the moment somebody asks it.
|
||||
func NewBase(app core.App) *Store {
|
||||
return &Store{shelf: durable{app: app}, maxCases: DefaultMaxCases, retention: DefaultClosedCaseRetention}
|
||||
}
|
||||
|
||||
func (d durable) put(c *types.Case) error {
|
||||
found, err := caseKind.Find(d.app, c.OrgID, fieldCase+" = {:id}", "", 1, dbx.Params{"id": c.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r *core.Record
|
||||
if len(found) > 0 {
|
||||
r = found[0]
|
||||
} else if r, err = caseKind.New(d.app, c.OrgID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.Set(fieldCase, c.ID)
|
||||
r.Set(fieldNumber, c.Number)
|
||||
r.Set(fieldStatus, c.Status)
|
||||
r.Set(fieldSeverity, c.Severity)
|
||||
r.Set(fieldEntities, c.EntityIDs)
|
||||
r.Set(fieldAlerts, c.AlertIDs)
|
||||
r.Set(fieldAssignee, c.AssigneeID)
|
||||
r.Set(fieldOpened, c.OpenedAt)
|
||||
if c.ClosedAt != nil {
|
||||
r.Set(fieldClosed, *c.ClosedAt)
|
||||
} else {
|
||||
r.Set(fieldClosed, nil)
|
||||
}
|
||||
r.Set(fieldResolution, c.Resolution)
|
||||
r.Set(fieldAssessment, c.Assessment)
|
||||
r.Set(fieldCreated, c.CreatedAt)
|
||||
r.Set(fieldUpdated, c.UpdatedAt)
|
||||
|
||||
if err := d.app.Save(r); err != nil {
|
||||
return fmt.Errorf("cases: save %s: %w", c.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// get reads a case by id across orgs, because an id is all the caller has when
|
||||
// it asks. The tenant is checked by that caller against the case it gets back —
|
||||
// which is what [Store.Get]'s callers already do, and the only place it is done.
|
||||
func (d durable) get(id string) (*types.Case, error) {
|
||||
found, err := caseKind.Across(d.app, fieldCase+" = {:id}", "", 1, dbx.Params{"id": id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return asCase(found[0]), nil
|
||||
}
|
||||
|
||||
func (d durable) list(org, status string) ([]*types.Case, error) {
|
||||
filter, params := "", dbx.Params{}
|
||||
if status != "" {
|
||||
filter, params = fieldStatus+" = {:status}", dbx.Params{"status": status}
|
||||
}
|
||||
found, err := caseKind.Find(d.app, org, filter, "-"+fieldNumber, 0, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*types.Case, 0, len(found))
|
||||
for _, r := range found {
|
||||
out = append(out, asCase(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d durable) each(visit func(*types.Case) error) error {
|
||||
found, err := caseKind.Across(d.app, "", "-"+fieldNumber, 0, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range found {
|
||||
if err := visit(asCase(r)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d durable) appendEvent(e types.CaseEvent) error {
|
||||
owner, err := d.get(e.CaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
r, err := eventKind.New(d.app, owner.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Set(fieldCase, e.CaseID)
|
||||
r.Set(fieldEvent, e.ID)
|
||||
r.Set(fieldAuthor, e.AuthorID)
|
||||
r.Set(fieldKind, e.Kind)
|
||||
r.Set(fieldBody, e.Body)
|
||||
r.Set(fieldFile, e.FilePath)
|
||||
r.Set(fieldAt, e.CreatedAt)
|
||||
if err := d.app.Save(r); err != nil {
|
||||
return fmt.Errorf("cases: save event on %s: %w", e.CaseID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d durable) events(caseID string) ([]types.CaseEvent, error) {
|
||||
found, err := eventKind.Across(d.app, fieldCase+" = {:id}", fieldAt, 0, dbx.Params{"id": caseID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]types.CaseEvent, 0, len(found))
|
||||
for _, r := range found {
|
||||
out = append(out, types.CaseEvent{
|
||||
ID: r.GetString(fieldEvent),
|
||||
CaseID: r.GetString(fieldCase),
|
||||
AuthorID: r.GetString(fieldAuthor),
|
||||
Kind: r.GetString(fieldKind),
|
||||
Body: r.GetString(fieldBody),
|
||||
FilePath: r.GetString(fieldFile),
|
||||
CreatedAt: r.GetDateTime(fieldAt).Time().UTC(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d durable) drop(ids []string) error {
|
||||
for _, id := range ids {
|
||||
cs, err := caseKind.Across(d.app, fieldCase+" = {:id}", "", 0, dbx.Params{"id": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evs, err := eventKind.Across(d.app, fieldCase+" = {:id}", "", 0, dbx.Params{"id": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range append(cs, evs...) {
|
||||
if err := d.app.Delete(r); err != nil {
|
||||
return fmt.Errorf("cases: delete %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// number continues the sequence across a restart by reading the highest one
|
||||
// written. A counter that starts again at one gives two cases the same number,
|
||||
// and a case number is what a file is referred to by.
|
||||
func (d durable) number() (int64, error) {
|
||||
found, err := caseKind.Across(d.app, "", "-"+fieldNumber, 1, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return 1, nil
|
||||
}
|
||||
return int64(found[0].GetInt(fieldNumber)) + 1, nil
|
||||
}
|
||||
|
||||
func asCase(r *core.Record) *types.Case {
|
||||
c := &types.Case{
|
||||
ID: r.GetString(fieldCase),
|
||||
OrgID: r.GetString(store.Org),
|
||||
Number: int64(r.GetInt(fieldNumber)),
|
||||
Status: r.GetString(fieldStatus),
|
||||
Severity: r.GetString(fieldSeverity),
|
||||
AssigneeID: r.GetString(fieldAssignee),
|
||||
OpenedAt: r.GetDateTime(fieldOpened).Time().UTC(),
|
||||
Resolution: r.GetString(fieldResolution),
|
||||
Assessment: r.GetString(fieldAssessment),
|
||||
CreatedAt: r.GetDateTime(fieldCreated).Time().UTC(),
|
||||
UpdatedAt: r.GetDateTime(fieldUpdated).Time().UTC(),
|
||||
}
|
||||
r.UnmarshalJSONField(fieldEntities, &c.EntityIDs)
|
||||
r.UnmarshalJSONField(fieldAlerts, &c.AlertIDs)
|
||||
if closed := r.GetDateTime(fieldClosed); !closed.IsZero() {
|
||||
at := closed.Time().UTC()
|
||||
c.ClosedAt = &at
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// stillOpen reports whether a closed case is inside the retention window.
|
||||
func stillOpen(c *types.Case, cutoff time.Time) bool {
|
||||
return c.Status != types.CaseClosed || c.ClosedAt == nil || !c.ClosedAt.Before(cutoff)
|
||||
}
|
||||
+118
-94
@@ -4,42 +4,44 @@ package cases
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// DefaultClosedCaseRetention is how long closed cases stay in memory.
|
||||
// DefaultClosedCaseRetention is how long a closed case is kept before eviction.
|
||||
const DefaultClosedCaseRetention = 90 * 24 * time.Hour // 90 days
|
||||
|
||||
// DefaultMaxCases is the maximum open+recent cases held in memory.
|
||||
// DefaultMaxCases is the point at which eviction runs.
|
||||
const DefaultMaxCases = 100_000
|
||||
|
||||
// Store is an in-memory case store with eviction of old closed cases.
|
||||
// RED-08: Prevents unbounded memory growth by evicting closed cases
|
||||
// older than the retention period, and hard-capping total count.
|
||||
// Store is the case plane: the cases, their timelines, and the decisions that
|
||||
// closed them.
|
||||
//
|
||||
// The state lives on a [shelf], which is either durable or in memory. What an
|
||||
// instance serves from is the durable one ([NewBase]); [NewStore] is the memory
|
||||
// one and is for tests.
|
||||
//
|
||||
// The mutex is still here, and still does the same job: a status change is a
|
||||
// read, a mutation and a write, and two of those interleaved lose one. It
|
||||
// serialises them within the process. A second replica would need the database
|
||||
// to do it instead — this deployment runs one, which is why one lock is enough
|
||||
// and why that is worth stating rather than assuming.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
cases map[string]*types.Case
|
||||
events map[string][]types.CaseEvent
|
||||
seq atomic.Int64
|
||||
mu sync.Mutex
|
||||
shelf shelf
|
||||
maxCases int
|
||||
retention time.Duration
|
||||
}
|
||||
|
||||
// NewStore creates an empty case store with default limits.
|
||||
// NewStore creates an empty in-memory case store. Nothing in it survives the
|
||||
// process; [NewBase] is what an instance serves from.
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
cases: make(map[string]*types.Case),
|
||||
events: make(map[string][]types.CaseEvent),
|
||||
maxCases: DefaultMaxCases,
|
||||
retention: DefaultClosedCaseRetention,
|
||||
}
|
||||
return &Store{shelf: newMemory(), maxCases: DefaultMaxCases, retention: DefaultClosedCaseRetention}
|
||||
}
|
||||
|
||||
// NewStoreWithLimits creates a case store with explicit limits.
|
||||
// NewStoreWithLimits creates an in-memory case store with explicit limits.
|
||||
func NewStoreWithLimits(maxCases int, retention time.Duration) *Store {
|
||||
if maxCases <= 0 {
|
||||
maxCases = DefaultMaxCases
|
||||
@@ -47,49 +49,60 @@ func NewStoreWithLimits(maxCases int, retention time.Duration) *Store {
|
||||
if retention <= 0 {
|
||||
retention = DefaultClosedCaseRetention
|
||||
}
|
||||
return &Store{
|
||||
cases: make(map[string]*types.Case),
|
||||
events: make(map[string][]types.CaseEvent),
|
||||
maxCases: maxCases,
|
||||
retention: retention,
|
||||
}
|
||||
return &Store{shelf: newMemory(), maxCases: maxCases, retention: retention}
|
||||
}
|
||||
|
||||
// EvictExpired removes closed cases older than the retention period.
|
||||
// Called internally after Create; can also be called externally by a cron.
|
||||
//
|
||||
// It evicts only what has been closed for longer than the window, so an open
|
||||
// case is never dropped however many there are: the alternative — capping by
|
||||
// count and evicting the oldest — throws away the investigations nobody has
|
||||
// finished, which are the ones that matter.
|
||||
func (s *Store) EvictExpired() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.evictExpiredLocked()
|
||||
return s.evictLocked()
|
||||
}
|
||||
|
||||
func (s *Store) evictExpiredLocked() int {
|
||||
func (s *Store) evictLocked() int {
|
||||
cutoff := time.Now().UTC().Add(-s.retention)
|
||||
evicted := 0
|
||||
for id, c := range s.cases {
|
||||
if c.Status == types.CaseClosed && c.ClosedAt != nil && c.ClosedAt.Before(cutoff) {
|
||||
delete(s.cases, id)
|
||||
delete(s.events, id)
|
||||
evicted++
|
||||
var expired []string
|
||||
_ = s.shelf.each(func(c *types.Case) error {
|
||||
if !stillOpen(c, cutoff) {
|
||||
expired = append(expired, c.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if len(expired) == 0 {
|
||||
return 0
|
||||
}
|
||||
return evicted
|
||||
if err := s.shelf.drop(expired); err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(expired)
|
||||
}
|
||||
|
||||
// Len returns the number of cases in the store.
|
||||
// Len returns the number of cases held.
|
||||
func (s *Store) Len() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.cases)
|
||||
n := 0
|
||||
_ = s.shelf.each(func(*types.Case) error { n++; return nil })
|
||||
return n
|
||||
}
|
||||
|
||||
// Create opens a new case from a set of alerts.
|
||||
func (s *Store) Create(orgID string, severity string, alertIDs []string, entityIDs []string) *types.Case {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
number, err := s.shelf.number()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
c := &types.Case{
|
||||
ID: uuid.NewString(),
|
||||
OrgID: orgID,
|
||||
Number: s.seq.Add(1),
|
||||
Number: number,
|
||||
Status: types.CaseOpen,
|
||||
Severity: severity,
|
||||
AlertIDs: alertIDs,
|
||||
@@ -98,39 +111,34 @@ func (s *Store) Create(orgID string, severity string, alertIDs []string, entityI
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.cases[c.ID] = c
|
||||
// RED-08: Evict expired closed cases when approaching capacity.
|
||||
if len(s.cases) > s.maxCases {
|
||||
s.evictExpiredLocked()
|
||||
if err := s.shelf.put(c); err != nil {
|
||||
return nil
|
||||
}
|
||||
if s.Len() > s.maxCases {
|
||||
s.evictLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns a case by ID.
|
||||
// Get returns a case by ID, or nil.
|
||||
//
|
||||
// It does not scope by tenant, because the caller holding an id is the one that
|
||||
// knows whose it should be — every caller checks OrgID against its own tenant,
|
||||
// and doing it here as well would put the check in two places and make the
|
||||
// second one the easy one to forget.
|
||||
func (s *Store) Get(id string) *types.Case {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cases[id]
|
||||
c, err := s.shelf.get(id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// List returns cases for an org, optionally filtered by status.
|
||||
// List returns cases for an org, optionally filtered by status, newest first.
|
||||
func (s *Store) List(orgID, status string) []*types.Case {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var out []*types.Case
|
||||
for _, c := range s.cases {
|
||||
if c.OrgID != orgID {
|
||||
continue
|
||||
}
|
||||
if status != "" && c.Status != status {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
out, err := s.shelf.list(orgID, status)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -140,30 +148,29 @@ func (s *Store) UpdateStatus(orgID, caseID, status, authorID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
c, ok := s.cases[caseID]
|
||||
if !ok || c.OrgID != orgID {
|
||||
return ErrNotFound
|
||||
c, err := s.owned(orgID, caseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldStatus := c.Status
|
||||
c.Status = status
|
||||
old := c.Status
|
||||
now := time.Now().UTC()
|
||||
c.Status = status
|
||||
c.UpdatedAt = now
|
||||
|
||||
if status == types.CaseClosed {
|
||||
c.ClosedAt = &now
|
||||
}
|
||||
|
||||
s.events[caseID] = append(s.events[caseID], types.CaseEvent{
|
||||
if err := s.shelf.put(c); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.shelf.appendEvent(types.CaseEvent{
|
||||
ID: uuid.NewString(),
|
||||
CaseID: caseID,
|
||||
AuthorID: authorID,
|
||||
Kind: types.EventStatusChange,
|
||||
Body: oldStatus + " -> " + status,
|
||||
Body: old + " -> " + status,
|
||||
CreatedAt: now,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEvent appends to a case timeline after confirming the case belongs to the
|
||||
@@ -179,23 +186,22 @@ func (s *Store) AddEvent(orgID, caseID string, evt types.CaseEvent) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
c, ok := s.cases[caseID]
|
||||
if !ok || c.OrgID != orgID {
|
||||
return ErrNotFound
|
||||
if _, err := s.owned(orgID, caseID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt.ID = uuid.NewString()
|
||||
evt.CaseID = caseID
|
||||
evt.CreatedAt = time.Now().UTC()
|
||||
s.events[caseID] = append(s.events[caseID], evt)
|
||||
return nil
|
||||
return s.shelf.appendEvent(evt)
|
||||
}
|
||||
|
||||
// Events returns the timeline for a case.
|
||||
// Events returns the timeline for a case, oldest first.
|
||||
func (s *Store) Events(caseID string) []types.CaseEvent {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.events[caseID]
|
||||
out, err := s.shelf.events(caseID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Assign sets the assignee for a case.
|
||||
@@ -203,13 +209,16 @@ func (s *Store) Assign(caseID, assigneeID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
c, ok := s.cases[caseID]
|
||||
if !ok {
|
||||
c, err := s.shelf.get(caseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.AssigneeID = assigneeID
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
return s.shelf.put(c)
|
||||
}
|
||||
|
||||
// Resolve closes a case with a resolution, against the retained assessment that
|
||||
@@ -229,9 +238,9 @@ func (s *Store) Resolve(orgID, caseID, resolution, authorID, assessment string)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
c, ok := s.cases[caseID]
|
||||
if !ok || c.OrgID != orgID {
|
||||
return ErrNotFound
|
||||
c, err := s.owned(orgID, caseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -240,8 +249,10 @@ func (s *Store) Resolve(orgID, caseID, resolution, authorID, assessment string)
|
||||
c.Assessment = assessment
|
||||
c.ClosedAt = &now
|
||||
c.UpdatedAt = now
|
||||
|
||||
s.events[caseID] = append(s.events[caseID], types.CaseEvent{
|
||||
if err := s.shelf.put(c); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.shelf.appendEvent(types.CaseEvent{
|
||||
ID: uuid.NewString(),
|
||||
CaseID: caseID,
|
||||
AuthorID: authorID,
|
||||
@@ -249,6 +260,19 @@ func (s *Store) Resolve(orgID, caseID, resolution, authorID, assessment string)
|
||||
Body: "closed: " + resolution + " (assessment " + assessment + ")",
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
// owned resolves a case within a tenant. A case in another tenant is reported the
|
||||
// same way one that does not exist is, because the caller may not tell them apart:
|
||||
// a case id is not a secret, and a different answer for each would enumerate
|
||||
// another institution's cases.
|
||||
func (s *Store) owned(orgID, caseID string) (*types.Case, error) {
|
||||
c, err := s.shelf.get(caseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == nil || c.OrgID != orgID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package cases
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// age rewinds a case's clock so a test can make one look old. It goes through
|
||||
// the shelf because the state is the shelf's, and the two shelves have to be
|
||||
// aged the same way for the same test to hold against both.
|
||||
func age(t *testing.T, s *Store, id string, opened, updated time.Time) *types.Case {
|
||||
t.Helper()
|
||||
c, err := s.shelf.get(id)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("age %s: %v", id, err)
|
||||
}
|
||||
if !opened.IsZero() {
|
||||
c.OpenedAt = opened
|
||||
}
|
||||
if !updated.IsZero() {
|
||||
c.UpdatedAt = updated
|
||||
}
|
||||
if err := s.shelf.put(c); err != nil {
|
||||
t.Fatalf("age %s: %v", id, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package cases
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/base/tests"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// TestCasesSurviveARestart is the case plane's half of the record-keeping
|
||||
// obligation, stated as a test.
|
||||
//
|
||||
// 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). The timeline
|
||||
// is the evidence of the work: who looked, what they found, what they concluded.
|
||||
// Held in a map, all of it goes when the pod does — and an investigation that
|
||||
// disappears on a rollout is not one anybody can be asked to stand behind.
|
||||
//
|
||||
// The second instance is a fresh app over the first one's bytes: new bootstrap,
|
||||
// new handles, nothing carried in memory. That is what a restarted pod reads.
|
||||
func TestCasesSurviveARestart(t *testing.T) {
|
||||
first, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatalf("first instance: %v", err)
|
||||
}
|
||||
if err := Ensure(first); err != nil {
|
||||
t.Fatalf("Ensure: %v", err)
|
||||
}
|
||||
|
||||
before := NewBase(first)
|
||||
opened := before.Create("hanzo/acme", types.SeverityCritical, []string{"al-1", "al-2"}, []string{"cust-1042"})
|
||||
if opened == nil {
|
||||
t.Fatal("Create returned no case")
|
||||
}
|
||||
if opened.Number != 1 {
|
||||
t.Fatalf("first case number = %d, want 1", opened.Number)
|
||||
}
|
||||
if err := before.AddEvent("hanzo/acme", opened.ID, types.CaseEvent{
|
||||
Kind: types.EventNote,
|
||||
AuthorID: "a.mensah",
|
||||
Body: "source of funds requested",
|
||||
}); err != nil {
|
||||
t.Fatalf("add note: %v", err)
|
||||
}
|
||||
|
||||
dir := copyTree(t, first.DataDir())
|
||||
first.Cleanup()
|
||||
|
||||
// The restart.
|
||||
second, err := tests.NewTestApp(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("second instance: %v", err)
|
||||
}
|
||||
t.Cleanup(second.Cleanup)
|
||||
after := NewBase(second)
|
||||
|
||||
got := after.Get(opened.ID)
|
||||
if got == nil {
|
||||
t.Fatal("the case opened before the restart is not there after it — the case plane is not durable")
|
||||
}
|
||||
if got.OrgID != "hanzo/acme" || got.Severity != types.SeverityCritical || got.Status != types.CaseOpen {
|
||||
t.Fatalf("the case came back changed: %+v", got)
|
||||
}
|
||||
if len(got.AlertIDs) != 2 || got.AlertIDs[0] != "al-1" {
|
||||
t.Fatalf("the alerts the case was opened from did not survive: %v", got.AlertIDs)
|
||||
}
|
||||
|
||||
timeline := after.Events(opened.ID)
|
||||
if len(timeline) != 1 || timeline[0].Body != "source of funds requested" {
|
||||
t.Fatalf("the timeline did not survive: %+v", timeline)
|
||||
}
|
||||
if timeline[0].AuthorID != "a.mensah" {
|
||||
t.Fatalf("the note lost its author, which is what makes it evidence: %+v", timeline[0])
|
||||
}
|
||||
|
||||
// Tenancy still holds on the other side of the restart.
|
||||
if err := after.AddEvent("zoo/acme", opened.ID, types.CaseEvent{Kind: types.EventNote, Body: "x"}); err == nil {
|
||||
t.Fatal("another tenant wrote on this case after the restart")
|
||||
}
|
||||
if got := after.List("zoo/acme", ""); len(got) != 0 {
|
||||
t.Fatalf("another tenant listed this case after the restart: %v", got)
|
||||
}
|
||||
if got := after.List("hanzo/acme", ""); len(got) != 1 {
|
||||
t.Fatalf("the owner's list came back with %d cases, want 1", len(got))
|
||||
}
|
||||
|
||||
// The numbering continues rather than starting again, or two cases share a
|
||||
// number and a case number is what a file is referred to by.
|
||||
next := after.Create("hanzo/acme", types.SeverityLow, nil, nil)
|
||||
if next == nil {
|
||||
t.Fatal("Create after the restart returned no case")
|
||||
}
|
||||
if next.Number != 2 {
|
||||
t.Fatalf("case number after the restart = %d, want 2", next.Number)
|
||||
}
|
||||
|
||||
// And the decision that closes it is retained too.
|
||||
if err := after.Resolve("hanzo/acme", opened.ID, types.ResolutionFalsePositive, "a.mensah", "as-1"); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
closed := after.Get(opened.ID)
|
||||
if closed.Status != types.CaseClosed || closed.Assessment != "as-1" {
|
||||
t.Fatalf("the resolution did not stick: %+v", closed)
|
||||
}
|
||||
}
|
||||
|
||||
// copyTree copies a data directory as it stands, so a second instance can be
|
||||
// opened over the same bytes after the first one is gone.
|
||||
func copyTree(t *testing.T, src string) string {
|
||||
t.Helper()
|
||||
dst := t.TempDir()
|
||||
err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("copy data dir: %v", err)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cases
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// shelf is where cases and their timelines are kept.
|
||||
//
|
||||
// It is an interface for the same reason the retention ledger's is: an invariant
|
||||
// that holds only in memory is not an invariant of this package. A case is the
|
||||
// record of a decision about whether to report — kept under AMLR Art. 77(1)(b)
|
||||
// whichever way the decision went — so the shelf an instance serves from has to
|
||||
// be the durable one, and the memory one exists to make the tests fast rather
|
||||
// than to run anything.
|
||||
//
|
||||
// Every method is keyed the way its caller is. Reads that name an org are scoped
|
||||
// to it here; reads that name only an id are not, because the id is the key and
|
||||
// the tenant is checked by the caller that holds it — the same shape the memory
|
||||
// shelf always had, so the check stays in exactly one place per operation.
|
||||
type shelf interface {
|
||||
// put writes a case, whether it is new or a change to one that exists.
|
||||
put(c *types.Case) error
|
||||
// get returns a case by id, or nil when there is none.
|
||||
get(id string) (*types.Case, error)
|
||||
// list returns an org's cases, optionally narrowed to one status.
|
||||
list(org, status string) ([]*types.Case, error)
|
||||
// each visits every case in every org. It is what a sweep is: triage and
|
||||
// eviction ask about cases regardless of whose they are.
|
||||
each(visit func(*types.Case) error) error
|
||||
// appendEvent adds one entry to a case's timeline.
|
||||
appendEvent(e types.CaseEvent) error
|
||||
// events is a case's timeline, oldest first.
|
||||
events(caseID string) ([]types.CaseEvent, error)
|
||||
// drop removes cases and their timelines.
|
||||
drop(ids []string) error
|
||||
// number is the next case number. It is the shelf's because it has to
|
||||
// continue across a restart, and a counter that starts again at one makes
|
||||
// two cases with the same number.
|
||||
number() (int64, error)
|
||||
}
|
||||
|
||||
// memory is the shelf tests run against. Nothing in it survives the process, so
|
||||
// it is not what an instance serves from — see [NewBase].
|
||||
type memory struct {
|
||||
mu sync.RWMutex
|
||||
cases map[string]*types.Case
|
||||
timeline map[string][]types.CaseEvent
|
||||
seq int64
|
||||
}
|
||||
|
||||
func newMemory() *memory {
|
||||
return &memory{
|
||||
cases: make(map[string]*types.Case),
|
||||
timeline: make(map[string][]types.CaseEvent),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memory) put(c *types.Case) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
copied := *c
|
||||
m.cases[c.ID] = &copied
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memory) get(id string) (*types.Case, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
c, ok := m.cases[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
copied := *c
|
||||
return &copied, nil
|
||||
}
|
||||
|
||||
func (m *memory) list(org, status string) ([]*types.Case, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []*types.Case
|
||||
for _, c := range m.cases {
|
||||
if c.OrgID != org {
|
||||
continue
|
||||
}
|
||||
if status != "" && c.Status != status {
|
||||
continue
|
||||
}
|
||||
copied := *c
|
||||
out = append(out, &copied)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Number > out[j].Number })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memory) each(visit func(*types.Case) error) error {
|
||||
m.mu.RLock()
|
||||
ids := make([]*types.Case, 0, len(m.cases))
|
||||
for _, c := range m.cases {
|
||||
copied := *c
|
||||
ids = append(ids, &copied)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, c := range ids {
|
||||
if err := visit(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memory) appendEvent(e types.CaseEvent) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.timeline[e.CaseID] = append(m.timeline[e.CaseID], e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memory) events(caseID string) ([]types.CaseEvent, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return append([]types.CaseEvent(nil), m.timeline[caseID]...), nil
|
||||
}
|
||||
|
||||
func (m *memory) drop(ids []string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
delete(m.cases, id)
|
||||
delete(m.timeline, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memory) number() (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.seq++
|
||||
return m.seq, nil
|
||||
}
|
||||
+24
-14
@@ -4,6 +4,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
@@ -51,14 +53,13 @@ func (s *Store) AutoAssign(c *types.Case, analysts []string, mode string) string
|
||||
|
||||
case "least_loaded":
|
||||
// Count open cases per analyst.
|
||||
s.mu.RLock()
|
||||
loads := make(map[string]int)
|
||||
for _, existing := range s.cases {
|
||||
_ = s.shelf.each(func(existing *types.Case) error {
|
||||
if existing.Status != types.CaseClosed && existing.AssigneeID != "" {
|
||||
loads[existing.AssigneeID]++
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
minLoad := -1
|
||||
for _, a := range analysts {
|
||||
@@ -98,24 +99,28 @@ func (s *Store) CheckEscalation(c *types.Case, config TriageConfig) bool {
|
||||
|
||||
// SLA breached: escalate.
|
||||
s.mu.Lock()
|
||||
existing, ok := s.cases[c.ID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
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.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
|
||||
_ = s.shelf.appendEvent(types.CaseEvent{
|
||||
ID: uuid.NewString(),
|
||||
Kind: types.EventStatusChange,
|
||||
CaseID: c.ID,
|
||||
AuthorID: "system",
|
||||
Body: "auto-escalated: SLA breached",
|
||||
CreatedAt: now,
|
||||
})
|
||||
s.mu.Unlock()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -152,12 +157,12 @@ func (s *Store) AutoEscalateStale(config TriageConfig) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, c := range s.cases {
|
||||
_ = s.shelf.each(func(c *types.Case) error {
|
||||
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
if !c.UpdatedAt.Before(threshold) {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
|
||||
// Every severity, not only low. The original only swept low-severity cases,
|
||||
@@ -165,8 +170,12 @@ func (s *Store) AutoEscalateStale(config TriageConfig) int {
|
||||
// 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.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
|
||||
_ = s.shelf.appendEvent(types.CaseEvent{
|
||||
ID: uuid.NewString(),
|
||||
Kind: types.EventStatusChange,
|
||||
CaseID: c.ID,
|
||||
AuthorID: "system",
|
||||
@@ -174,7 +183,8 @@ func (s *Store) AutoEscalateStale(config TriageConfig) int {
|
||||
CreatedAt: now,
|
||||
})
|
||||
escalated++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return escalated
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestCheckEscalation_SLABreached(t *testing.T) {
|
||||
|
||||
// Backdate the case to trigger SLA breach.
|
||||
s.mu.Lock()
|
||||
s.cases[c.ID].OpenedAt = time.Now().UTC().Add(-2 * time.Hour)
|
||||
c = age(t, s, c.ID, time.Now().UTC().Add(-2*time.Hour), time.Time{})
|
||||
s.mu.Unlock()
|
||||
|
||||
config := DefaultTriageConfig()
|
||||
@@ -119,7 +119,7 @@ func TestAStaleCaseIsEscalatedNotClosed(t *testing.T) {
|
||||
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)
|
||||
age(t, s, c.ID, time.Time{}, time.Now().UTC().Add(-60*24*time.Hour))
|
||||
s.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestNoTimerPathCanCloseACase(t *testing.T) {
|
||||
s := NewStore()
|
||||
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||
s.mu.Lock()
|
||||
s.cases[c.ID].UpdatedAt = time.Now().UTC().Add(-365 * 24 * time.Hour)
|
||||
age(t, s, c.ID, time.Time{}, time.Now().UTC().Add(-365*24*time.Hour))
|
||||
s.mu.Unlock()
|
||||
|
||||
s.AutoEscalateStale(DefaultTriageConfig())
|
||||
@@ -194,13 +194,13 @@ func TestTriageCheck_FullCycle(t *testing.T) {
|
||||
// Critical case past SLA.
|
||||
c2 := s.Create("org1", types.SeverityCritical, nil, nil)
|
||||
s.mu.Lock()
|
||||
s.cases[c2.ID].OpenedAt = time.Now().UTC().Add(-3 * time.Hour)
|
||||
c2 = age(t, s, c2.ID, time.Now().UTC().Add(-3*time.Hour), time.Time{})
|
||||
s.mu.Unlock()
|
||||
|
||||
// Stale low-severity case.
|
||||
c3 := s.Create("org1", types.SeverityLow, nil, nil)
|
||||
s.mu.Lock()
|
||||
s.cases[c3.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour)
|
||||
age(t, s, c3.ID, time.Time{}, time.Now().UTC().Add(-60*24*time.Hour))
|
||||
s.mu.Unlock()
|
||||
|
||||
config := DefaultTriageConfig()
|
||||
|
||||
Reference in New Issue
Block a user