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.
This commit is contained in:
306
internal/adapters/postgres/approvals.go
Normal file
306
internal/adapters/postgres/approvals.go
Normal file
@@ -0,0 +1,306 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user