Files
oikos/internal/adapters/postgres/approvals.go
dtoro b98d7c24bf
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run
feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
  full decide transaction: HMAC token verify, approval flip, execution un-gate,
  session-scoped window keys (+session suffix matching GovernanceStore gate),
  nomos session flip, audit+event on failure abort. httpapi DecideApproval now
  a thin presenter delegating to the service. ListPending payload format fixed
  (json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
  / ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
  fixed (defer/recover per execution), correlation_id preserved via Finalize
  event emission (ExecRunRepo.Finalize now emits execution.{status} with
  correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
  exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
2026-08-16 12:29:59 +02:00

307 lines
10 KiB
Go

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:<id>.session:<sid>" 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())
}