package db import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "log/slog" "time" "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/core/ports" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) const ( assentWindowDuration = 30 * time.Minute destructiveWindowDuration = 15 * time.Minute ) // ApprovalRepo implements ports.ApprovalRepository over the postgres pool. // Decide verifies the HMAC token (check-then-act), flips the approval, // un-gates the linked execution, opens assent/destructive windows, flips // the nomos session out of awaiting_input, and appends audit + event — one // transaction (ADR 0016 §3.6). type ApprovalRepo struct { pool *Pool } var _ ports.ApprovalRepository = (*ApprovalRepo)(nil) // NewApprovalRepo builds the approval repository. func NewApprovalRepo(pool *Pool) *ApprovalRepo { return &ApprovalRepo{pool: pool} } // ListPending returns pending approvals for the given entity (or all pending // when entityID is empty), newest first. func (r *ApprovalRepo) ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error) { if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } rows, err := r.pool.Query(ctx, ` SELECT a.entity_id, COALESCE(a.subject_entity_id::text, ''), a.action, a.risk_class, a.kind, a.payload, a.status, COALESCE(a.token_hash, ''), a.expires_at, a.decided_at, COALESCE(a.decided_by::text, ''), a.created_at FROM approvals a WHERE ($1::uuid IS NULL OR a.entity_id = $1) AND a.status = 'pending' ORDER BY a.created_at DESC LIMIT $2`, uuidOrNil(entityID), limit) if err != nil { return nil, err } defer rows.Close() var out []domain.Approval for rows.Next() { var a domain.Approval var subjectStr, tokenHash, decidedBy string var payload []byte if err := rows.Scan(&a.EntityID, &subjectStr, &a.Action, &a.RiskClass, &a.Kind, &payload, &a.Status, &tokenHash, &a.ExpiresAt, &a.DecidedAt, &decidedBy, &a.CreatedAt); err != nil { return nil, err } a.TokenHash = tokenHash if subjectStr != "" { a.SubjectEntityID = domain.UUID(subjectStr) } if decidedBy != "" { a.DecidedBy = domain.UUID(decidedBy) } if len(payload) > 0 { var m map[string]any if json.Unmarshal(payload, &m) == nil { a.Payload = m } } out = append(out, a) } return out, rows.Err() } // Decide runs the full decide transaction. Returns the updated approval and, // on approve with a linked execution, the Resume info for the driving adapter // to dispatch the SSH work after commit. func (r *ApprovalRepo) Decide(ctx context.Context, in ports.ApprovalDecideInput) (ports.ApprovalDecideResult, error) { id := mustUUID(in.ApprovalID) tx, err := r.pool.Begin(ctx) if err != nil { return ports.ApprovalDecideResult{}, err } defer tx.Rollback(ctx) q := sqlcgen.New(tx) // Verify HMAC token if provided (single-use, S5). if in.Token != "" { var tokenHash *string var apprStatus string var expiresAt time.Time err := tx.QueryRow(ctx, "SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1", id).Scan(&tokenHash, &apprStatus, &expiresAt) if err != nil || tokenHash == nil { return ports.ApprovalDecideResult{}, domain.ErrNotFound } if apprStatus != "pending" { return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition } if expiresAt.Before(time.Now()) { return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition } if *tokenHash != hashApprovalToken(in.Token) { return ports.ApprovalDecideResult{}, domain.ErrInvalidInput } } if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{ EntityID: id, Status: in.Status, }); err != nil { return ports.ApprovalDecideResult{}, domain.ErrNotFound } // Re-read approval. app, err := q.GetApprovalByID(ctx, id) if err != nil { return ports.ApprovalDecideResult{}, err } approval := domain.Approval{ EntityID: domain.UUID(app.EntityID.String()), Action: app.Action, RiskClass: app.RiskClass, Kind: app.Kind, Status: app.Status, TokenHash: stringPtrOr(app.TokenHash), ExpiresAt: app.ExpiresAt, DecidedAt: app.DecidedAt, DecidedBy: uuidPtrOr(app.DecidedBy), CreatedAt: app.CreatedAt, } if app.SubjectEntityID != nil { approval.SubjectEntityID = domain.UUID(app.SubjectEntityID.String()) } // Audit + event (in-tx; NOTIFY fires post-commit). An audit failure must // abort the decision — leaving no trace of who decided would violate the // append-only audit contract (ADR 0016 §3.6). actorLabel := in.Actor if actorLabel == "" { actorLabel = "unknown" } if auditErr := observability.Audit(ctx, q, "api", actorLabel, "decide", &id, "POST", "/api/v1/approvals/"+id.String()+"/decision", "", nil, map[string]any{"decision": in.Status}); auditErr != nil { return ports.ApprovalDecideResult{}, auditErr } if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "", map[string]any{"decision": in.Status, "actor": actorLabel}); evErr != nil { return ports.ApprovalDecideResult{}, evErr } result := ports.ApprovalDecideResult{Approval: approval} // Approve: un-gate the linked execution and open windows. if in.Status == "approved" { resume, aerr := r.approveSideEffects(ctx, tx, id) if aerr != nil { return result, aerr } result.Resume = resume } else { // Denied/revoked: reflect on the linked execution. _, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, in.Status) } // Flip any nomos session out of awaiting_input — the operator answered. flipAwaitingSession(ctx, tx, id, in.Status) if err := tx.Commit(ctx); err != nil { return result, err } return result, nil } // approveSideEffects finds the linked pending execution, marks it approved, // opens the assent window (+ destructive window for destructive risk), and // returns the Resume info for the adapter to dispatch the SSH work. func (r *ApprovalRepo) approveSideEffects(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID) (*ports.ApprovalResume, error) { var execID, targetID uuid.UUID var actionStr, targetSlug, riskClass, sessionID string err := tx.QueryRow(ctx, ` SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class, COALESCE((SELECT pe.session_id FROM nomos_plan_executions pe WHERE pe.execution_id = e.entity_id LIMIT 1)::text, '') FROM executions e WHERE e.approval_id = $1 AND e.status = 'pending_approval' LIMIT 1`, approvalID).Scan(&execID, &targetID, &actionStr, &riskClass, &sessionID) if err != nil { slog.Warn("postgres: no pending execution found for approval", "approval_id", approvalID, "error", err) return nil, nil } _ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug) // Status only — risk_class was set correctly at request time (e.g. by // policy.ClassifyCommand for `run`); overwriting it to a hardcoded // 'config_mutation' here corrupted the audit ledger for every other risk // class, including destructive. _, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID) var agentID *uuid.UUID if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil { // Approving a plan step — by ANY route (the UI Approve button and // chat-assent) — opens/extends the agent's assent window. The key is // session-scoped so the window only auto-runs THIS task's plan steps // (governance.go AssentWindowActive + session store's AssentWindowKey // use the same "assent_window.agent:.session:" format). expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339) _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String()+".session:"+sessionID, expires) // Approving a DESTRUCTIVE step via the button is exactly as explicit // as a typed "I confirm" — open the same short, target+session-scoped // destructive window chat-assent's typed-confirm path opens. if riskClass == "destructive" && targetSlug != "" { dExpires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339) _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`, "destructive_window.agent:"+agentID.String()+".target:"+targetSlug+".session:"+sessionID, dExpires) } } slog.Info("postgres: approved execution queued", "execution_id", execID, "target", targetSlug, "action", actionStr) return &ports.ApprovalResume{ ExecutionID: domain.UUID(execID.String()), TargetSlug: targetSlug, Action: actionStr, }, nil } // flipAwaitingSession marks any nomos session linked to this approval as // executing again (awaiting_input → executing), mirroring // classifyAndGate's awaiting_input flip in reverse. func flipAwaitingSession(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID, status string) { var awaitingSessionID string _ = tx.QueryRow(ctx, ` SELECT pe.session_id FROM nomos_plan_executions pe JOIN executions ex ON ex.entity_id = pe.execution_id WHERE ex.approval_id = $1 LIMIT 1`, approvalID).Scan(&awaitingSessionID) if awaitingSessionID == "" { return } rtag, rerr := tx.Exec(ctx, ` UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID) if rerr != nil || rtag.RowsAffected() == 0 { return } var taskEntID *uuid.UUID var e uuid.UUID if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil { taskEntID = &e } _ = observability.Event(ctx, sqlcgen.New(tx), "task.status", taskEntID, "info", "api", awaitingSessionID, map[string]any{"status": "executing", "reason": "approval_decided", "decision": status}) } // hashApprovalToken computes a SHA-256 hex-encoded hash of the token, used // to verify single-use approval tokens (S5) without storing the plaintext. func hashApprovalToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) } func uuidOrNil(id domain.UUID) any { if id == "" { return nil } return mustUUID(id) } func stringPtrOr(s *string) string { if s == nil { return "" } return *s } func uuidPtrOr(u *uuid.UUID) domain.UUID { if u == nil { return "" } return domain.UUID(u.String()) }