Files
zeekayandHanzo Dev 04accf64e0 engine, history: one definition of a subject that names nobody
Subject.Valid is the single place that decides what an unusable subject is —
unknown kind, no tenant, no identifier — and the scope that builds subjects and
both stores that consume them all defer to it. Each of those failures has the
same consequence and it is the worst one available: a subject naming nobody
yields an empty window, and a rule over an empty window sees no activity, which
is indistinguishable from a customer who did nothing.

A rule that reaches no verdict now records review regardless of the action it was
configured with. A failing flag-only rule escalates, because nobody looks at a
flag; a failing block rule does not block, because an evidence outage would
otherwise decline every payment.

Reference data refuses rather than guesses: an unloaded or undated jurisdiction
listing fails loudly instead of answering not-listed for every country on earth.

Dropped the empty-expression check, which the parser already makes.

22 of 22 mutations to the engine, history and reference guards are killed by the
suite; 58 of 58 across measure, sanctions and engine together.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:01:22 -07:00

90 lines
2.2 KiB
Go

package history
import (
"context"
"sort"
"sync"
"time"
)
// Memory is an in-process Store.
//
// It exists so the detection logic can be exercised at the level a reviewer cares
// about — given this sequence of transactions, does the rule fire — without
// standing up a database. It applies the same tenant scoping and the same
// timestamp semantics as the persistent store, because a test against a store
// with weaker rules than production proves nothing about production.
type Memory struct {
mu sync.RWMutex
events []Event
orgs []string
now func() time.Time
}
// NewMemory builds an empty in-process store. now supplies the reference instant
// for the lookback; nil means the wall clock.
func NewMemory(now func() time.Time) *Memory {
return &Memory{now: now}
}
// Append records an event for an organisation.
func (m *Memory) Append(orgID string, e Event) {
m.mu.Lock()
defer m.mu.Unlock()
m.events = append(m.events, e)
m.orgs = append(m.orgs, orgID)
}
// Window returns the subject's events over the lookback, most recent first.
func (m *Memory) Window(_ context.Context, subj Subject, lookback time.Duration) ([]Event, error) {
if err := subj.Valid(); err != nil {
return nil, err
}
field, _ := identify(subj.Kind)
since := m.reference().Add(-lookback)
m.mu.RLock()
defer m.mu.RUnlock()
var out []Event
for i, e := range m.events {
if m.orgs[i] != subj.OrgID {
continue
}
if field(e) != subj.ID {
continue
}
if e.At.Before(since) {
continue
}
out = append(out, e)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
return out, nil
}
func (m *Memory) reference() time.Time {
if m.now != nil {
return m.now()
}
return time.Now().UTC()
}
// identify returns the accessor for a subject kind.
func identify(kind string) (func(Event) string, bool) {
switch kind {
case SubjectUser:
return func(e Event) string { return e.User }, true
case SubjectAccount:
return func(e Event) string { return e.Account }, true
case SubjectCounterparty:
return func(e Event) string { return e.Counterparty }, true
case SubjectDevice:
return func(e Event) string { return e.Device }, true
case SubjectAddress:
return func(e Event) string { return e.Address }, true
}
return nil, false
}