Files
Andy Dixon b35dbe0299 security: add the capability and policy layer as the only path to action
Phase 12 (ADR-0005). Capabilities are registered in code with parameter
schemas, risk classes and default effects; sudo_shell and delete_database
are locked to DENY and cannot be relaxed. Policies are scoped globally or
per mind with capability, target glob, action kind and optional hour
window; evaluation is deterministic, most specific wins, and ties go to
the stricter effect. Invoke is the single entry point: it refuses
dreaming and replay contexts, validates parameters (unknown keys, non
http URLs and path traversal rejected), evaluates policy and session
grants, hashes the exact request and records a policy_decision event.
ALLOW executes with timeout and panic isolation and turns textual output
into a tool-trust observation; ASK creates an approval request bound to
the hash with an alert notification; DENY is counted and recorded.

Operators approve once, approve for a time-boxed session grant, deny
with a note, or modify parameters (re-validated, re-hashed, original
kept in the append-only decision log). The self-model's capability list
is recomputed from effective policy on start and on every policy change.
The act process maps fetch requests to http.fetch and the action handler
invokes through the layer; replies report capabilities and outcomes.

Policy matrix and approval centre pages, dashboard count, API, CLI
(approvals, policy, invoke) and docs/capabilities.md.
2026-06-29 20:28:05 +01:00

208 lines
8.8 KiB
Go

// Package notifications decides when something warrants human attention and
// records why. Rules are explicit and thresholded; repeats within a cooldown
// are aggregated rather than re-sent (spec §39).
package notifications
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Notification is one item for a human.
type Notification struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Level string `json:"level"` // info | warn | alert
Title string `json:"title"`
Body string `json:"body"`
Rule string `json:"rule"`
Reason json.RawMessage `json:"reason"`
AggregateKey string `json:"aggregate_key"`
Count int `json:"count"`
Status string `json:"status"` // new | read | suppressed
SourceEvent ids.ID `json:"source_event,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
}
// Candidate is a notification a rule proposes before suppression.
type Candidate struct {
Level string
Title string
Body string
Rule string
Reason map[string]any
AggregateKey string
SourceEvent ids.ID
}
// Rules evaluates one event against the mind's thresholds. It is the whole
// policy of what deserves attention, kept small and legible.
func Rules(ev events.Event, cfg minds.CognitiveConfig, p minds.Personality) []Candidate {
var out []Candidate
proactive := p.Behaviour.NotifyProactively
switch ev.Type {
case events.PredictionError:
conf, _ := ev.Metadata["confidence"].(float64)
if conf >= 0.7 {
out = append(out, Candidate{Level: "warn", Title: "A confident prediction failed", Body: ev.Summary, Rule: "prediction_error",
Reason: map[string]any{"confidence": conf, "threshold": 0.7}, AggregateKey: "prediction_error:" + str(ev.Metadata["prediction_id"]), SourceEvent: ev.ID})
}
case events.ContradictionDetected:
if ev.Confidence >= 0.6 {
out = append(out, Candidate{Level: "warn", Title: "Two beliefs contradict each other", Body: ev.Summary, Rule: "contradiction",
Reason: map[string]any{"similarity": ev.Confidence, "threshold": 0.6}, AggregateKey: "contradiction:" + str(ev.Metadata["belief_id"]), SourceEvent: ev.ID})
}
case events.BeliefCreated, events.BeliefUpdated, events.HypothesisSupported:
if proactive && ev.Confidence >= cfg.NotifyMinConfidence && ev.Salience >= cfg.NotifyMinSalience {
out = append(out, Candidate{Level: "info", Title: "A belief became strong", Body: ev.Summary, Rule: "belief_confident",
Reason: map[string]any{"confidence": ev.Confidence, "min_confidence": cfg.NotifyMinConfidence, "salience": ev.Salience, "min_salience": cfg.NotifyMinSalience}, AggregateKey: "belief:" + str(ev.Metadata["belief_id"]), SourceEvent: ev.ID})
}
case events.GoalCompleted:
if proactive {
out = append(out, Candidate{Level: "info", Title: "Goal completed", Body: ev.Summary, Rule: "goal_completed", Reason: map[string]any{}, AggregateKey: "goal:" + str(ev.Metadata["goal_id"]), SourceEvent: ev.ID})
}
case events.Metacognition:
if ev.Metadata["check"] == "overconfidence" || ev.Metadata["check"] == "loop" {
out = append(out, Candidate{Level: "warn", Title: "Metacognitive warning", Body: ev.Summary, Rule: "metacognition", Reason: map[string]any{"check": ev.Metadata["check"]}, AggregateKey: "meta:" + str(ev.Metadata["check"]), SourceEvent: ev.ID})
}
case events.ToolRequested:
out = append(out, Candidate{Level: "alert", Title: "An action needs your approval", Body: ev.Summary, Rule: "tool_requested", Reason: map[string]any{"risk": ev.Metadata["risk"]}, AggregateKey: "approval:" + str(ev.Metadata["approval_id"]), SourceEvent: ev.ID})
case events.ToolDenied:
out = append(out, Candidate{Level: "alert", Title: "An action was denied by policy", Body: ev.Summary, Rule: "tool_denied", Reason: map[string]any{}, AggregateKey: "denied:" + str(ev.Metadata["capability"]), SourceEvent: ev.ID})
case events.Observation:
if ev.Salience >= 0.95 && proactive {
out = append(out, Candidate{Level: "alert", Title: "High-salience observation", Body: ev.Summary, Rule: "observation_salient", Reason: map[string]any{"salience": ev.Salience, "threshold": 0.95}, AggregateKey: "obs:" + firstWords(ev.Summary, 4), SourceEvent: ev.ID})
}
}
return out
}
func str(v any) string {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
func firstWords(s string, n int) string {
f := strings.Fields(strings.ToLower(s))
if len(f) > n {
f = f[:n]
}
return strings.Join(f, " ")
}
// Store persists notifications.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// Deliver records a candidate unless one with the same aggregate key exists
// within the cooldown, in which case that one's count is incremented and the
// candidate is reported as suppressed. Returns the notification and whether
// it was suppressed.
func (s *Store) Deliver(ctx context.Context, q storage.Querier, mind ids.ID, c Candidate, cooldown time.Duration) (Notification, bool, error) {
if c.AggregateKey != "" {
var n Notification
err := q.QueryRow(ctx, `UPDATE notifications SET count=count+1, updated_at=now() WHERE id = (
SELECT id FROM notifications WHERE mind_id=$1 AND aggregate_key=$2 AND created_at > $3 ORDER BY created_at DESC LIMIT 1) RETURNING id, count`, mind, c.AggregateKey, time.Now().Add(-cooldown)).Scan(&n.ID, &n.Count)
if err == nil {
n.MindID, n.AggregateKey, n.Rule, n.Title = mind, c.AggregateKey, c.Rule, c.Title
return n, true, nil
}
if err != pgx.ErrNoRows {
return Notification{}, false, err
}
}
reason, _ := json.Marshal(c.Reason)
if c.Reason == nil {
reason = []byte("{}")
}
n := Notification{ID: ids.New(), MindID: mind, Level: c.Level, Title: c.Title, Body: c.Body, Rule: c.Rule, Reason: reason, AggregateKey: c.AggregateKey, Count: 1, Status: "new", SourceEvent: c.SourceEvent, CreatedAt: time.Now(), UpdatedAt: time.Now()}
_, err := q.Exec(ctx, `INSERT INTO notifications (id, mind_id, level, title, body, rule, reason, aggregate_key, source_event) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
n.ID, n.MindID, n.Level, n.Title, n.Body, n.Rule, reason, n.AggregateKey, nullID(n.SourceEvent))
return n, false, err
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
const cols = `id, mind_id, level, title, body, rule, reason, aggregate_key, count, status, source_event, created_at, updated_at, read_at`
func scan(row pgx.Row) (Notification, error) {
var n Notification
var src *string
if err := row.Scan(&n.ID, &n.MindID, &n.Level, &n.Title, &n.Body, &n.Rule, &n.Reason, &n.AggregateKey, &n.Count, &n.Status, &src, &n.CreatedAt, &n.UpdatedAt, &n.ReadAt); err != nil {
return n, storage.Translate(err)
}
if src != nil {
n.SourceEvent = ids.ID(*src)
}
return n, nil
}
// List returns notifications newest first; mind Nil means all minds.
func (s *Store) List(ctx context.Context, mind ids.ID, status string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM notifications WHERE ($1::uuid IS NULL OR mind_id=$1) AND ($2='' OR status=$2) ORDER BY updated_at DESC LIMIT $3`, nullID(mind), status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Notification
for rows.Next() {
n, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
// MarkRead sets status read.
func (s *Store) MarkRead(ctx context.Context, mind, id ids.ID) error {
tag, err := s.db.Exec(ctx, `UPDATE notifications SET status='read', read_at=now(), updated_at=now() WHERE id=$1 AND ($2::uuid IS NULL OR mind_id=$2) AND status='new'`, id, nullID(mind))
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// MarkAllRead marks a mind's new notifications read.
func (s *Store) MarkAllRead(ctx context.Context, mind ids.ID) (int64, error) {
tag, err := s.db.Exec(ctx, `UPDATE notifications SET status='read', read_at=now(), updated_at=now() WHERE ($1::uuid IS NULL OR mind_id=$1) AND status='new'`, nullID(mind))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// CountNew counts unread notifications (all minds when Nil).
func (s *Store) CountNew(ctx context.Context, mind ids.ID) (int64, error) {
var n int64
err := s.db.QueryRow(ctx, `SELECT count(*) FROM notifications WHERE ($1::uuid IS NULL OR mind_id=$1) AND status='new'`, nullID(mind)).Scan(&n)
return n, err
}