// Package policy implements Oikos classification and policy evaluation. // Determines risk class, autonomy route, and approval requirements. package policy import ( "context" "encoding/json" "fmt" "github.com/dtoro/oikos/internal/domain" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // ClassificationResult holds the outcome of classifying a signal. type ClassificationResult struct { RiskClass string Route string // 'auto-act', 'escalate', 'hold' RecommendedAction json.RawMessage AutonomyCheck string BlastRadius []uuid.UUID CorrelationID string Reasoning json.RawMessage } // Classify evaluates a signal against policy rules to determine the action route. // ctx must have a DB connection pool accessible via a helper interface. type Classifier struct { DB interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) Exec(ctx context.Context, sql string, args ...any) (int64, error) } } // NewClassifier creates a classifier with a DB query interface. func NewClassifier(dbc interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) Exec(ctx context.Context, sql string, args ...any) (int64, error) }) *Classifier { return &Classifier{DB: dbc} } // ClassifySignal evaluates a signal and returns the classification result. func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetEntityID uuid.UUID, kind, severity, correlationID string) (*ClassificationResult, error) { // Determine target entity type var entityType string err := c.DB.QueryRow(ctx, "SELECT type FROM entities WHERE id = $1", targetEntityID).Scan(&entityType) if err != nil { return nil, fmt.Errorf("%w: target entity %s", domain.ErrNotFound, targetEntityID) } // Look up risk class for this entity type and action var riskClass string var approvalRequired string err = c.DB.QueryRow(ctx, ` SELECT rc.name, rc.approval_required FROM risk_classes rc WHERE rc.name = ( SELECT COALESCE(ar.risk_class, 'reversible_low') FROM approval_rules ar WHERE ar.entity_type = $1 AND ar.action = $2 LIMIT 1 )`, entityType, kind).Scan(&riskClass, &approvalRequired) if err != nil { // Default to escalate riskClass = "reversible_low" approvalRequired = "operator" } // Check global autonomy setting var globalAutoAct string err = c.DB.QueryRow(ctx, "SELECT value FROM autonomy_settings WHERE key = 'global.auto_act'").Scan(&globalAutoAct) if err != nil { globalAutoAct = "on" // default to on } // Check per-entity kill-switch var slug string c.DB.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetEntityID).Scan(&slug) var entityAutoAct string if slug != "" { c.DB.QueryRow(ctx, "SELECT value FROM autonomy_settings WHERE key = $1", "never_auto_act."+slug).Scan(&entityAutoAct) } // Determine route route := "escalate" autonomyCheck := "" if globalAutoAct == "off" || globalAutoAct == "false" { route = "escalate" autonomyCheck = "blocked: global auto_act disabled" } else if entityAutoAct == "true" { route = "escalate" autonomyCheck = "blocked: per-entity kill-switch" } else if approvalRequired == "none" { route = "auto-act" autonomyCheck = "allowed" } else { autonomyCheck = "requires approval: " + approvalRequired } // Compute blast radius blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID) reasoning := map[string]any{ "entity_type": entityType, "risk_class": riskClass, "approval_rule": approvalRequired, "global_auto_act": globalAutoAct, "entity_slug": slug, } reasoningJSON, _ := json.Marshal(reasoning) recommended, _ := json.Marshal(map[string]any{ "action": kind, "reason": fmt.Sprintf("signal %s on %s", severity, entityType), }) return &ClassificationResult{ RiskClass: riskClass, Route: route, RecommendedAction: recommended, AutonomyCheck: autonomyCheck, BlastRadius: blastRadius, CorrelationID: correlationID, Reasoning: reasoningJSON, }, nil } // computeBlastRadius traverses relationships to find affected entities. func computeBlastRadius(ctx context.Context, dbc interface { Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) }, entityID uuid.UUID) []uuid.UUID { rows, err := dbc.Query(ctx, ` SELECT entity_id FROM blast_radius($1, 3)`, entityID) if err != nil { return nil } defer rows.Close() var ids []uuid.UUID for rows.Next() { var id uuid.UUID if err := rows.Scan(&id); err == nil { ids = append(ids, id) } } return ids }