Files
noema/internal/events/store.go
T
Andy Dixon 7d608c2783 feat(privacy): audited redaction that keeps history's shape and removes its content
Rows in append-only history are never deleted; their content can now be
made irrecoverable through one audited path. A redaction replaces the
content columns of an event, memory, message, model call, belief or
assertion with a fixed marker, keeps identifiers, links, timestamps,
scores and breakdowns, writes an append-only ledger row with a SHA-256 of
the removed content, appends a redaction event, optionally cascades to
derived rows and to the events that quote them, and scrubs stored snapshot
archives before re-signing them. noema_redact() is the only write the
append-only trigger admits, for UPDATE only, under a transaction-local flag
the application never sets; plain updates and all deletes stay refused.
Replay labels redacted stimuli instead of inventing them.

Cryptographic erasure was considered and deferred with reasons (ADR-0009);
docs/privacy.md defines retained, redacted, irrecoverable and deleted and
states what backups and earlier exports are not covered. Migration 0023.
2026-08-23 23:52:07 +01:00

446 lines
13 KiB
Go

package events
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Store appends and queries cognitive events.
type Store struct {
db *pgxpool.Pool
bus *Bus
metrics *observability.Metrics
}
// NewStore creates the store. bus and metrics may be nil in tests.
func NewStore(db *pgxpool.Pool, bus *Bus, metrics *observability.Metrics) *Store {
if bus == nil {
bus = NewBus(1000)
}
return &Store{db: db, bus: bus, metrics: metrics}
}
// Bus exposes the fan-out bus for subscribers.
func (s *Store) Bus() *Bus { return s.bus }
// ErrInvalid marks malformed events.
var ErrInvalid = errors.New("invalid event")
func validate(n *events.New) error {
if n.MindID.IsZero() {
return fmt.Errorf("%w: mind id required", ErrInvalid)
}
if !n.Type.Valid() {
return fmt.Errorf("%w: unknown type %q", ErrInvalid, n.Type)
}
if strings.TrimSpace(n.Summary) == "" {
return fmt.Errorf("%w: summary required", ErrInvalid)
}
if n.Source == "" {
n.Source = "unknown"
}
if len(n.Summary) > 2000 {
n.Summary = n.Summary[:2000]
}
n.Salience = clamp(n.Salience, 0, 1)
n.Confidence = clamp(n.Confidence, 0, 1)
if n.Valence != nil {
v := clamp(*n.Valence, -1, 1)
n.Valence = &v
}
if n.Arousal != nil {
a := clamp(*n.Arousal, 0, 1)
n.Arousal = &a
}
if n.OccurredAt.IsZero() {
n.OccurredAt = time.Now()
}
parents := n.ParentIDs[:0]
for _, p := range n.ParentIDs {
if !p.IsZero() {
parents = append(parents, p)
}
}
n.ParentIDs = parents
return nil
}
func clamp(v, lo, hi float64) float64 {
if v != v { // NaN
return lo
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// Append writes events in one transaction and publishes them after commit.
func (s *Store) Append(ctx context.Context, news ...events.New) ([]events.Event, error) {
var out []events.Event
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
var err error
out, err = s.AppendIn(ctx, tx, news...)
return err
})
if err != nil {
return nil, err
}
s.Publish(out...)
return out, nil
}
// AppendIn writes events inside the caller's transaction. The caller must call
// Publish after committing so subscribers never see uncommitted events.
func (s *Store) AppendIn(ctx context.Context, q storage.Querier, news ...events.New) ([]events.Event, error) {
out := make([]events.Event, 0, len(news))
for i := range news {
n := &news[i]
if err := validate(n); err != nil {
return nil, err
}
payload, err := json.Marshal(n.Payload)
if err != nil {
return nil, fmt.Errorf("%w: payload: %v", ErrInvalid, err)
}
if n.Payload == nil {
payload = []byte("{}")
}
if n.Evidence == nil {
n.Evidence = []events.EvidenceRef{}
}
if n.Metadata == nil {
n.Metadata = map[string]any{}
}
evidence, _ := json.Marshal(n.Evidence)
metadata, _ := json.Marshal(n.Metadata)
if n.ParentIDs == nil {
n.ParentIDs = []ids.ID{}
}
ev := events.Event{ID: ids.New(), MindID: n.MindID, Type: n.Type, Category: events.CategoryOf(n.Type), Source: n.Source,
OccurredAt: n.OccurredAt, ParentIDs: n.ParentIDs, CausationID: n.CausationID, CorrelationID: n.CorrelationID,
Salience: n.Salience, Confidence: n.Confidence, Valence: n.Valence, Arousal: n.Arousal, Evidence: n.Evidence,
Summary: n.Summary, Metadata: n.Metadata, Payload: payload}
err = q.QueryRow(ctx, `INSERT INTO cognitive_events (id, mind_id, type, category, source, occurred_at, parent_ids, causation_id, correlation_id,
salience, confidence, valence, arousal, evidence, summary, metadata, payload)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING seq, recorded_at`,
ev.ID, ev.MindID, string(ev.Type), string(ev.Category), ev.Source, ev.OccurredAt, uuidArray(ev.ParentIDs), nullID(ev.CausationID), nullID(ev.CorrelationID),
ev.Salience, ev.Confidence, ev.Valence, ev.Arousal, evidence, ev.Summary, metadata, payload).Scan(&ev.Seq, &ev.RecordedAt)
if err != nil {
return nil, fmt.Errorf("append event: %w", err)
}
out = append(out, ev)
}
return out, nil
}
// Publish fans committed events out to live subscribers and metrics.
func (s *Store) Publish(evs ...events.Event) {
if s.metrics != nil {
for _, ev := range evs {
s.metrics.Events.WithLabelValues(string(ev.Type)).Inc()
}
}
s.bus.Publish(evs...)
}
func uuidArray(in []ids.ID) []string {
out := make([]string, len(in))
for i, id := range in {
out[i] = string(id)
}
return out
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
// Filter selects events. Zero values mean "no constraint".
type Filter struct {
MindID ids.ID
Types []events.Type
Categories []events.Category
Sources []string
MinSalience float64
MinConfidence float64
MaxConfidence float64 // 0 = unset
Since, Until time.Time
AfterSeq int64 // exclusive lower bound (forward paging / cursors)
BeforeSeq int64 // exclusive upper bound (backward paging)
CorrelationID ids.ID
Search string // substring of summary, case-insensitive
Limit int
Ascending bool
}
const cols = `id, seq, mind_id, type, category, source, occurred_at, recorded_at, parent_ids, causation_id, correlation_id, salience, confidence, valence, arousal, evidence, summary, metadata, payload, redacted_at`
// Query returns events matching f ordered by seq (descending unless Ascending).
func (s *Store) Query(ctx context.Context, f Filter) ([]events.Event, error) {
var where []string
var args []any
arg := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) }
if !f.MindID.IsZero() {
where = append(where, "mind_id = "+arg(f.MindID))
}
if len(f.Types) > 0 {
ts := make([]string, len(f.Types))
for i, t := range f.Types {
ts[i] = string(t)
}
where = append(where, "type = ANY("+arg(ts)+")")
}
if len(f.Categories) > 0 {
cs := make([]string, len(f.Categories))
for i, c := range f.Categories {
cs[i] = string(c)
}
where = append(where, "category = ANY("+arg(cs)+")")
}
if len(f.Sources) > 0 {
where = append(where, "source = ANY("+arg(f.Sources)+")")
}
if f.MinSalience > 0 {
where = append(where, "salience >= "+arg(f.MinSalience))
}
if f.MinConfidence > 0 {
where = append(where, "confidence >= "+arg(f.MinConfidence))
}
if f.MaxConfidence > 0 {
where = append(where, "confidence <= "+arg(f.MaxConfidence))
}
if !f.Since.IsZero() {
where = append(where, "occurred_at >= "+arg(f.Since))
}
if !f.Until.IsZero() {
where = append(where, "occurred_at <= "+arg(f.Until))
}
if f.AfterSeq > 0 {
where = append(where, "seq > "+arg(f.AfterSeq))
}
if f.BeforeSeq > 0 {
where = append(where, "seq < "+arg(f.BeforeSeq))
}
if !f.CorrelationID.IsZero() {
where = append(where, "correlation_id = "+arg(f.CorrelationID))
}
if q := strings.TrimSpace(f.Search); q != "" {
where = append(where, "summary ILIKE "+arg("%"+escapeLike(q)+"%"))
}
limit := f.Limit
if limit <= 0 || limit > 1000 {
limit = 100
}
order := "DESC"
if f.Ascending {
order = "ASC"
}
sql := "SELECT " + cols + " FROM cognitive_events"
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += " ORDER BY seq " + order + " LIMIT " + arg(limit)
rows, err := s.db.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// Get fetches one event by id.
func (s *Store) Get(ctx context.Context, id ids.ID) (events.Event, error) {
ev, err := scan(s.db.QueryRow(ctx, "SELECT "+cols+" FROM cognitive_events WHERE id=$1", id))
return ev, storage.Translate(err)
}
// GetMany fetches events by id, preserving no particular order.
func (s *Store) GetMany(ctx context.Context, idList []ids.ID) ([]events.Event, error) {
if len(idList) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE id = ANY($1) ORDER BY seq", uuidArray(idList))
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
// Children returns events directly caused by, or listing as parent, the given event.
func (s *Store) Children(ctx context.Context, id ids.ID, limit int) ([]events.Event, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE causation_id=$1 OR $1 = ANY(parent_ids) ORDER BY seq LIMIT $2", id, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
// DerivedFrom returns events that cite id as evidence.
func (s *Store) DerivedFrom(ctx context.Context, kind string, id string, limit int) ([]events.Event, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
ref, _ := json.Marshal([]map[string]string{{"kind": kind, "id": id}})
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE evidence @> $1::jsonb ORDER BY seq LIMIT $2", ref, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func scan(row pgx.Row) (events.Event, error) {
var ev events.Event
var typ, cat string
var parents []string
var causation, correlation *string
var evidence, metadata []byte
err := row.Scan(&ev.ID, &ev.Seq, &ev.MindID, &typ, &cat, &ev.Source, &ev.OccurredAt, &ev.RecordedAt, &parents, &causation, &correlation,
&ev.Salience, &ev.Confidence, &ev.Valence, &ev.Arousal, &evidence, &ev.Summary, &metadata, &ev.Payload, &ev.RedactedAt)
if err != nil {
return ev, err
}
ev.Type, ev.Category = events.Type(typ), events.Category(cat)
ev.ParentIDs = make([]ids.ID, len(parents))
for i, p := range parents {
ev.ParentIDs[i] = ids.ID(p)
}
if causation != nil {
ev.CausationID = ids.ID(*causation)
}
if correlation != nil {
ev.CorrelationID = ids.ID(*correlation)
}
json.Unmarshal(evidence, &ev.Evidence) //nolint:errcheck
json.Unmarshal(metadata, &ev.Metadata) //nolint:errcheck
if ev.Evidence == nil {
ev.Evidence = []events.EvidenceRef{}
}
if ev.Metadata == nil {
ev.Metadata = map[string]any{}
}
return ev, nil
}
// Cursor returns the last sequence consumed by consumer for a mind.
//
// seq is an ordering key, not a counter: bigserial values are skipped when a
// transaction rolls back, so consumers always ask for "seq > cursor" and
// never for "cursor + 1" (ADR-0002).
func (s *Store) Cursor(ctx context.Context, q storage.Querier, mind ids.ID, consumer string) (int64, error) {
var seq int64
err := q.QueryRow(ctx, `SELECT last_seq FROM mind_cursors WHERE mind_id=$1 AND consumer=$2`, mind, consumer).Scan(&seq)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return seq, err
}
// SetCursor advances a consumer cursor (never backwards).
func (s *Store) SetCursor(ctx context.Context, q storage.Querier, mind ids.ID, consumer string, seq int64) error {
_, err := q.Exec(ctx, `INSERT INTO mind_cursors (mind_id, consumer, last_seq) VALUES ($1,$2,$3)
ON CONFLICT (mind_id, consumer) DO UPDATE SET last_seq = GREATEST(mind_cursors.last_seq, excluded.last_seq), updated_at = now()`, mind, consumer, seq)
return err
}
// Stats summarises a mind's events for overview pages.
type Stats struct {
Total int64 `json:"total"`
ByCategory map[string]int64 `json:"by_category"`
LastSeq int64 `json:"last_seq"`
LastAt *time.Time `json:"last_at,omitempty"`
}
// Stats counts events for a mind (optionally since a time).
func (s *Store) Stats(ctx context.Context, mind ids.ID, since time.Time) (Stats, error) {
st := Stats{ByCategory: map[string]int64{}}
rows, err := s.db.Query(ctx, `SELECT category, count(*), max(seq), max(occurred_at) FROM cognitive_events WHERE mind_id=$1 AND ($2::timestamptz IS NULL OR occurred_at >= $2) GROUP BY category`, mind, nullTime(since))
if err != nil {
return st, err
}
defer rows.Close()
for rows.Next() {
var cat string
var n, maxSeq int64
var at *time.Time
if err := rows.Scan(&cat, &n, &maxSeq, &at); err != nil {
return st, err
}
st.ByCategory[cat] = n
st.Total += n
if maxSeq > st.LastSeq {
st.LastSeq, st.LastAt = maxSeq, at
}
}
return st, rows.Err()
}
func nullTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}