Files
oikos/internal/httpapi/phase3.go
dtoro c5ffaec85b fix(agent): panic recovery on every background goroutine (B1+B2)
Fixes B1 and B2 of plans/2026-07-11-nomos-agent-code-review.md together,
since the right granularity for B1 in the auto-continuation worker turned
out to require B2's restructuring anyway (see below).

B1: grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/ returned
nothing before this — every explicitly-spawned goroutine (continuation
worker, resumed chat turns, async execution dispatch, the SSE listener, two
duplicate sshExec implementations' output-collector goroutines) crashed the
whole process on an unhandled panic, not just that one goroutine. More
consequential post-concurrency: more simultaneous unattended background work
means more surface area for one bad input to end every running task.

New internal/safego package: Go(label, fn) launches fn in a goroutine with a
recover-and-log wrapper. Applied at every bare `go` spawn site across the
three packages. Two sites needed bespoke handling instead of the generic
helper because their callers block on a channel and a silent recover would
just make them hang until timeout: sshExec's output-collector goroutine (two
near-identical copies, internal/mcp/server.go and internal/httpapi/phase3.go)
and httpapi's ListenAndServe goroutine — both now recover AND send a
synthetic error result so the waiting select unblocks immediately instead of
waiting out the full timeout.

httpapi's sseListener got extra treatment: its per-notification handling was
extracted into handleNotification with its own recover, so a panic decoding
ONE malformed pg_notify payload can't kill the listener goroutine for every
connected SSE client — the outer goroutine spawn only needs to guard the
connection setup/reconnect code around it.

B2: cmd/nomos/continue.go's processContinuations used to run every pending
continuation SEQUENTIALLY in a plain for loop, in the SAME goroutine as the
ticker — meaning (a) task B's continuation waited for task A's full (up to
10-minute) resumed turn to finish first, undercutting this session's earlier
concurrency work on exactly the path autonomous tasks depend on most, and
(b) an unrecovered panic anywhere in that call chain didn't just crash the
process (B1) — even WITH B1's recovery wrapped only at the top-level worker
spawn, the panic would still unwind the ENTIRE ticker-loop goroutine,
silently ending auto-continuation for every task until nomos restarted.
Fixed by spawning each pending item via safego.Go individually: real
parallelism, and a bad item can now only ever take down its own goroutine.

Added internal/safego/safego_test.go: TestGo_RecoversPanic is the concrete
proof — a deliberate panic inside Go() that would otherwise crash the whole
test binary; reaching the assertion after it IS the evidence recovery works.

Verified live against the rebuilt containers: full chat turn round-tripped
correctly (hostname lookup, 2 iterations, normal completion) — no regression
from threading safego.Go through the tool-dispatch/continuation paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:05:19 +02:00

2628 lines
85 KiB
Go

package httpapi
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/ssh"
)
var (
_sshUser string
_sshKey []byte
)
// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes").
// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool`
// field made the approved pct_create execution fail to parse *after* the
// operator had already approved it — the container was never created and the
// operator saw "queued" with no result. This type tolerates the common shapes.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(strings.Trim(string(data), `"`))
switch strings.ToLower(s) {
case "true", "1", "yes", "on":
*b = true
case "false", "0", "no", "off", "", "null":
*b = false
default:
return fmt.Errorf("cannot parse %q as bool", s)
}
return nil
}
func initSSH() {
if _sshUser == "" {
_sshUser = os.Getenv("OIKOS_SSH_USER")
if _sshUser == "" {
_sshUser = "root"
}
}
if len(_sshKey) == 0 {
keyPath := os.Getenv("OIKOS_SSH_KEY_PATH")
if keyPath == "" {
keyPath = "/etc/oikos/ssh_key"
}
var err error
_sshKey, err = os.ReadFile(keyPath)
if err != nil {
slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err)
}
}
}
// sshExecTimeout bounds how long a single remote command may run. Without
// this, a hung remote command (e.g. a piped install script stuck retrying
// DNS against a misconfigured gateway) blocks the executing goroutine
// forever: the execution never leaves 'approved'/'running', the operator
// sees an unkillable spinner, and get_execution_status has nothing new to
// report. Generous enough for a real apt/docker install; not infinite.
const sshExecTimeout = 10 * time.Minute
func sshExec(ctx context.Context, host, user, command string) (string, error) {
initSSH()
if len(_sshKey) == 0 {
return "", fmt.Errorf("no SSH key available")
}
if user == "" {
user = _sshUser
}
addr := host + ":22"
signer, err := ssh.ParsePrivateKey(_sshKey)
if err != nil {
return "", fmt.Errorf("parse key: %w", err)
}
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return "", fmt.Errorf("dial %s: %w", host, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("session: %w", err)
}
defer session.Close()
type result struct {
out []byte
err error
}
done := make(chan result, 1)
go func() {
// See internal/mcp/server.go's sshExec for why this recovers rather
// than letting a rare SSH-library panic crash the whole api process.
defer func() {
if r := recover(); r != nil {
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
}
}()
out, err := session.CombinedOutput(command)
done <- result{out, err}
}()
select {
case r := <-done:
text := strings.TrimSpace(string(r.out))
// A non-zero exit MUST surface as an error. The previous guard only
// errored when there was no output, so a `pct create` that printed
// "CT 132 already exists" and exited non-zero was reported as
// success — the execution was marked completed though nothing was
// provisioned.
if r.err != nil {
if text != "" {
return text, fmt.Errorf("%w: %s", r.err, text)
}
return text, fmt.Errorf("exec: %w", r.err)
}
return text, nil
case <-time.After(sshExecTimeout):
// Close the session/client to hang up the remote side; the
// goroutine above will eventually exit once that unblocks
// CombinedOutput, but we don't wait for it — the caller needs an
// answer now, not an indefinite hang.
session.Close()
client.Close()
return "", fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
case <-ctx.Done():
session.Close()
client.Close()
return "", ctx.Err()
}
}
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {
var attrs string
err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs)
if err != nil {
return "", "", fmt.Errorf("entity not found: %s", entitySlug)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(attrs), &m); err != nil {
return "", "", fmt.Errorf("parse attributes: %w", err)
}
sshUser := _sshUser
if sshUser == "" {
sshUser = "root"
}
if ip, ok := m["lan_ip"].(string); ok && ip != "" {
return ip, sshUser, nil
}
if mesh, ok := m["mesh"].(map[string]interface{}); ok {
for _, proto := range []string{"netbird", "tailscale"} {
if p, ok := mesh[proto].(map[string]interface{}); ok {
if ip, ok := p["ip"].(string); ok && ip != "" {
return ip, sshUser, nil
}
}
}
}
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
}
// resolveRunTarget mirrors internal/mcp.resolveExecTarget for the approved-
// execution side: any target slug (host: or lxc:) resolves to the SSH
// endpoint that runs the command plus a wrap function that turns a plain
// shell command into what actually needs to be sent — identity for a host,
// `pct exec <pve_id>` for an LXC. Kept as a small duplicate rather than a
// cross-package import to avoid coupling httpapi to mcp for one helper.
func resolveRunTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(string) string, err error) {
if strings.HasPrefix(targetSlug, "host:") {
host, user, err = resolveHostSSH(ctx, pool, targetSlug)
return host, user, func(cmd string) string { return cmd }, err
}
if strings.HasPrefix(targetSlug, "lxc:") {
var pveID, hostAttr string
// COALESCE the host column: many older LXC entities (seeded from
// inventory, not provisioned by pct_create) have pve_id but no host
// attribute at all. Scanning a SQL NULL into a plain string errors
// the whole row, wrongly reporting "missing pve_id" even when it was
// present — COALESCE avoids the NULL, "" is handled below.
if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug)
}
hostSlug := hostAttr
if hostSlug == "" {
hostSlug = "hubris"
}
if !strings.HasPrefix(hostSlug, "host:") {
hostSlug = "host:" + hostSlug
}
host, user, err = resolveHostSSH(ctx, pool, hostSlug)
id := pveID
return host, user, func(cmd string) string {
b64 := base64.StdEncoding.EncodeToString([]byte(cmd))
return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64)
}, err
}
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
}
// executeApprovedAction runs a gated action after operator approval.
// Runs in a background goroutine to not block the HTTP response.
// emitExecutionEvent records an execution lifecycle event for SSE fan-out so
// the control room can watch approved actions run to completion live.
func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) {
severity := "info"
if status == "failed" {
severity = "warning"
}
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
if status == "completed" || status == "failed" || status == "cancelled" {
closePlanStepForExecution(ctx, pool, execID, status)
}
}
// closePlanStepForExecution auto-closes a task plan step whose linked execution
// just reached a terminal state, so the task board advances even if the agent
// doesn't call update_plan_step itself (belt and suspenders — the agent links
// the step to the execution when it starts it; the api finishes it here). Emits
// plan.step.finished correlated to the step's session. No-op for the vast
// majority of executions, which aren't plan steps.
func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) {
stepStatus := "done"
if execStatus == "failed" || execStatus == "cancelled" {
stepStatus = "failed"
}
var stepID, sessionID string
var seq int
if err := pool.QueryRow(ctx, `
UPDATE session_plan_steps SET status = $2, finished_at = now()
WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped')
RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil {
return // no matching open step
}
_ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID,
map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()})
}
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
host, user, wrap, err := resolveRunTarget(ctx, pool, targetSlug)
if err != nil {
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", err.Error()))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
return
}
idx := strings.Index(actionStr, ":")
if idx < 0 {
slog.Error("httpapi: malformed action string (no colon)", "action", actionStr)
return
}
action, params := actionStr[:idx], actionStr[idx+1:]
startedAt := time.Now()
var output, cmd string
switch action {
case "systemctl":
svc := strings.TrimPrefix(targetSlug, "lxc:")
switch {
case strings.HasPrefix(params, "enable:"):
svc = strings.TrimPrefix(params, "enable:")
cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
case strings.HasPrefix(params, "disable:"):
svc = strings.TrimPrefix(params, "disable:")
cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
default:
cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc)
}
output, err = sshExec(ctx, host, user, cmd)
case "apt_upgrade":
svc := strings.TrimPrefix(targetSlug, "lxc:")
cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc)
output, err = sshExec(ctx, host, user, cmd)
case "pct_create":
var cfg struct {
VMID int `json:"vmid"`
Hostname string `json:"hostname"`
Cores int `json:"cores"`
Memory int `json:"memory"`
DiskGB int `json:"disk_gb"`
IP string `json:"ip"`
GW string `json:"gw"`
Bridge string `json:"bridge"` // e.g. vmbr0/vmbr1 — which bridge actually reaches the target subnet on this host varies per host, don't assume vmbr0
Storage string `json:"storage"`
Template string `json:"template"`
Privileged flexBool `json:"privileged"`
Nesting flexBool `json:"nesting"`
Mounts []string `json:"mounts"`
Nameserver string `json:"nameserver"`
Searchdomain string `json:"searchdomain"`
// No services/post_install here anymore — pct_create is atomic
// (create + start + register only). Installing packages and
// running setup scripts is the agent's job via follow-up `run`
// calls against lxc:<hostname>, so each step is individually
// observable and recoverable instead of one opaque multi-minute
// black box. See the comment above the removed post-create block.
}
if err := json.Unmarshal([]byte(params), &cfg); err != nil {
slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("invalid pct_create params: %v", err))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
return
}
// Only hostname is required. vmid is optional — when 0 (or later found
// to collide) the VMID guard below assigns a free cluster id.
if cfg.Hostname == "" {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, `{"error":"pct_create: hostname is required"}`)
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"})
return
}
if cfg.Cores == 0 {
cfg.Cores = 1
}
if cfg.Memory == 0 {
cfg.Memory = 512
}
if cfg.DiskGB == 0 {
cfg.DiskGB = 8
}
if cfg.Storage == "" {
cfg.Storage = "local-lvm"
}
if cfg.GW == "" {
cfg.GW = "192.168.8.2"
}
if cfg.Nameserver == "" {
cfg.Nameserver = "192.168.8.2"
}
if cfg.Searchdomain == "" {
cfg.Searchdomain = "hubris.network"
}
// Template pre-flight: resolve against what the host actually has
// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
// `pct` error when that exact file isn't present. List the cache, then
// either validate the requested template or auto-pick the newest
// debian one; on miss, fail early with the available list so the
// operator/agent can retry with a real name.
cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true")
available := []string{}
for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") {
if l = strings.TrimSpace(l); l != "" {
available = append(available, l)
}
}
if tplErr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("list templates on %s: %s", targetSlug, tplErr.Error()))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()})
return
}
cfg.Template = resolveTemplate(cfg.Template, available)
if cfg.Template == "" {
msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
// VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's
// guess (e.g. 132) can collide with a container on another node — pct
// create then fails with "CT N already exists on node X". Fetch the set
// of in-use VMIDs across the cluster; if the requested id is taken (or
// absent), fall back to the cluster's next free id so provisioning
// still succeeds instead of dead-ending on the operator's approval.
usedRaw, _ := sshExec(ctx, host, user, `pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`)
used := map[int]bool{}
for _, l := range strings.Fields(usedRaw) {
if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
used[n] = true
}
}
if cfg.VMID == 0 || used[cfg.VMID] {
nextRaw, nerr := sshExec(ctx, host, user, `pvesh get /cluster/nextid 2>/dev/null`)
nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw))
if nerr != nil || cerr != nil || nextID == 0 {
msg := fmt.Sprintf("VMID %d is already in use on the cluster and could not resolve a free id", cfg.VMID)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
slog.Info("httpapi: pct_create VMID reassigned", "requested", cfg.VMID, "assigned", nextID)
cfg.VMID = nextID
}
privFlag := "--unprivileged 1"
if cfg.Privileged {
privFlag = "--unprivileged 0"
}
nestingFlag := ""
features := []string{}
if cfg.Nesting {
features = append(features, "nesting=1")
}
if cfg.Privileged {
features = append(features, "keyctl=1")
}
if len(features) > 0 {
nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ","))
}
if cfg.Bridge == "" {
cfg.Bridge = "vmbr0"
}
// net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox
// rejects a gateway alongside ip=dhcp, so only add gw for a static IP.
net0 := "name=eth0,bridge=" + cfg.Bridge + ","
isStatic := cfg.IP != "" && !strings.EqualFold(cfg.IP, "dhcp")
if !isStatic {
net0 += "ip=dhcp"
} else {
net0 += "ip=" + cfg.IP
if cfg.GW != "" {
net0 += ",gw=" + cfg.GW
}
}
// Pre-flight: for a static config, ping the gateway from the target
// HOST, on the SPECIFIC BRIDGE being requested, before spending 5+
// minutes creating the container. This is the check that would have
// caught the real TypeType failure immediately instead of after a
// full provision attempt.
//
// Binding to the bridge (`ping -I <bridge>`) matters and was found
// live: a plain unqualified `ping <gw>` from the host can succeed via
// the host's own routing table (multiple routes, possibly through an
// upstream router) even when the *container* — which only gets a
// naive on-link default route via its bridge's veth — can never ARP
// that gateway at all. Confirmed on `strong`: bare `ping 192.168.8.2`
// succeeded (via the host's default route), but a container actually
// attached to vmbr0 showed 100% packet loss trying to reach the same
// address, because vmbr0 doesn't carry that subnet's L2 segment.
// Binding to the bridge interface reproduces what the container will
// actually experience, not what the host's broader routing table can
// reach.
if isStatic && cfg.GW != "" {
pingOut, pingErr := sshExec(ctx, host, user, fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", cfg.Bridge, cfg.GW))
if pingErr != nil || !gatewayPreflightPassed(pingOut) {
msg := fmt.Sprintf(
"gateway %s is not reachable from %s on bridge %s — this almost always means the bridge doesn't carry that subnet on this host (each bridge only reaches the network it's physically wired to). "+
"Do not retry with a different gateway guess in the same subnet: find an existing LXC on this host with an IP in the same /28 and copy its exact bridge+gateway, or use DHCP instead.",
cfg.GW, targetSlug, cfg.Bridge)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
}
templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template)
createCmd := fmt.Sprintf(
"pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1",
cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory,
cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag)
if cfg.Nameserver != "" {
createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver)
}
if cfg.Searchdomain != "" {
createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain)
}
// Add mount points
for i, mp := range cfg.Mounts {
if i < 10 { // pct supports up to mp9
createCmd += fmt.Sprintf(" --mp%d %s", i, mp)
}
}
slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
output, err = sshExec(ctx, host, user, createCmd)
// pct_create is now DELIBERATELY ATOMIC: create + start + register,
// nothing else. It used to also run apt installs and a post_install
// script inline as one black-box multi-minute SSH call — the agent
// got back a single opaque success/fail for the whole thing with no
// way to see (or fix) which step actually broke. That's the opposite
// of what makes an agent able to recover from errors.
//
// Installing packages, running post_install, and verifying the
// service now happen as the agent's OWN follow-up `run` calls against
// the new lxc:<hostname> target — each one is synchronous (in an
// active assent window) or individually gated, so the agent observes
// every step's real output and can diagnose + retry the exact thing
// that failed instead of re-doing the whole container. See SOUL.md
// "After pct_create: you drive the install" and provisionScript's
// surviving role (DNS self-heal) is now something the agent invokes
// itself via `run`, not something baked into this handler.
//
// cfg.Services/cfg.PostInstall are intentionally no longer read here.
// On success, register the entity in the DB with proper relationships
if err == nil {
slug := "lxc:" + cfg.Hostname
var lxcID uuid.UUID
lxcID, _ = uuid.NewV7()
attrs := map[string]any{
"pve_id": fmt.Sprintf("%d", cfg.VMID),
"host": strings.TrimPrefix(targetSlug, "host:"),
"ip": cfg.IP,
}
attrsJSON, _ := json.Marshal(attrs)
_, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at)
VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON)
if insErr != nil {
slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug)
}
// Create hosts relationship: Proxmox host → LXC
var hostID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil {
_, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID)
if relErr != nil {
slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug)
}
}
// Create entity_status row for health tracking
pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at)
VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID)
emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{
"lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug,
})
slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug)
}
case "run":
// The general gated primitive: arbitrary shell against any host or
// LXC, approved and classified by internal/policy.ClassifyCommand at
// request time (see mcp/server.go's "run" tool). No fixed action
// enum — new capability doesn't require new Go code here.
var cfg struct {
Command string `json:"command"`
Purpose string `json:"purpose"`
}
if perr := json.Unmarshal([]byte(params), &cfg); perr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("invalid run params: %v", perr))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": perr.Error()})
return
}
cmd = wrap(cfg.Command)
output, err = sshExec(ctx, host, user, cmd)
default:
slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("unknown action: %s", action))
return
}
durationMs := int(time.Since(startedAt).Milliseconds())
status := "completed"
verified := true
// Build result via json.Marshal, not string interpolation. Command output
// (apt/pct) contains quotes, backslashes and control chars; the old
// fmt.Sprintf only escaped "\n", producing invalid JSON that failed the
// ::jsonb cast — so this UPDATE was silently discarded and the execution
// was stuck at "approved" forever even though provisioning succeeded.
resMap := map[string]any{"output": output}
if err != nil {
resMap["error"] = err.Error()
status = "failed"
verified = false
}
resultJSON, _ := json.Marshal(resMap)
if _, uerr := pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
execID, status, resultJSON, durationMs, verified, startedAt, time.Now()); uerr != nil {
slog.Error("httpapi: finalize execution status", "error", uerr, "execution_id", execID, "intended_status", status)
}
emitExecutionEvent(ctx, pool, execID, status, map[string]any{
"action": action, "target": targetSlug, "duration_ms": durationMs,
})
slog.Info("httpapi: approved action executed",
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
}
// jsonErr builds a valid {"error": "..."} JSON payload for an execution's
// result column. Always use this instead of fmt.Sprintf'ing JSON by hand —
// error text and command output routinely contain quotes/backslashes that
// break a hand-built string and fail the ::jsonb cast.
func jsonErr(format string, args ...any) []byte {
b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
return b
}
// resolveTemplate maps a requested template name to one actually present in
// the host's template cache. Exact match wins; a bare distro hint (e.g.
// "debian-13" or "debian") matches by prefix; empty picks the newest debian
// (falling back to any) template available. Returns "" when nothing fits.
// gatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL markers
// from the pct_create gateway pre-flight check. Pulled out as its own
// function (rather than an inline strings.Contains at the call site) so it's
// unit-testable: a prior version checked for "REACHABLE", which is a
// substring of "UNREACHABLE" — the check could never actually fail, and it
// took a live deployment to notice. Exact-match markers plus a test make
// that specific bug class structurally unable to recur silently.
func gatewayPreflightPassed(out string) bool {
return strings.TrimSpace(out) == "PREFLIGHT_OK"
}
func resolveTemplate(requested string, available []string) string {
if len(available) == 0 {
return ""
}
if requested != "" {
for _, a := range available {
if a == requested {
return a
}
}
for _, a := range available {
if strings.HasPrefix(a, requested) {
return a
}
}
}
// Auto-pick: prefer debian, then the lexically-greatest (newest version).
best := ""
for _, a := range available {
if strings.Contains(a, "debian") && a > best {
best = a
}
}
if best != "" {
return best
}
for _, a := range available {
if a > best {
best = a
}
}
return best
}
// ─── Checks ────────────────────────────────────────────────────────────
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT cd.entity_id, e.slug, cd.kind,
COALESCE(te.slug, '') AS target_slug, cd.target_type,
cd.config, cd.interval_s, cd.timeout_s, cd.zone, cd.enabled,
e.version
FROM check_defs cd
JOIN entities e ON e.id = cd.entity_id
LEFT JOIN entities te ON te.id = cd.target_id
WHERE ($1::text IS NULL OR cd.kind = $1)
AND ($2::text IS NULL OR te.slug = $2)
AND ($3::bool IS NULL OR cd.enabled = $3)
AND ($4::text IS NULL OR e.slug > $4)
ORDER BY e.slug
LIMIT $5`,
req.Params.Kind, req.Params.Target, req.Params.Enabled, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Check{}
for rows.Next() {
var c gen.Check
var targetSlug string
var configBytes []byte
if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType,
&configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version); err != nil {
return nil, err
}
if targetSlug != "" {
c.Target = &targetSlug
}
var config map[string]any
if len(configBytes) > 0 && json.Unmarshal(configBytes, &config) == nil && len(config) > 0 {
c.Config = &config
}
items = append(items, c)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Check{}
}
return gen.ListChecks200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObject) (gen.CreateCheckResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
slug := req.Body.Slug
if slug == "" {
slug = "check:" + string(req.Body.Kind) + ":" + uuid.New().String()[:8]
}
// Resolve target if provided.
var targetID *uuid.UUID
if req.Body.Target != nil && *req.Body.Target != "" {
tid, rerr := s.resolveEntityID(ctx, *req.Body.Target)
if rerr != nil {
return nil, rerr
}
targetID = &tid
}
intervalS := int32(300)
if req.Body.IntervalS != nil {
intervalS = int32(*req.Body.IntervalS)
}
timeoutS := int32(30)
if req.Body.TimeoutS != nil {
timeoutS = int32(*req.Body.TimeoutS)
}
enabled := true
if req.Body.Enabled != nil {
enabled = *req.Body.Enabled
}
configJSON := []byte("{}")
if req.Body.Config != nil {
configJSON, _ = json.Marshal(req.Body.Config)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Create the entity row (checks are entities).
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: slug,
Type: "check",
Name: slug,
Attributes: []byte("{}"),
})
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: check %q already exists", domain.ErrAlreadyExists, slug)
}
return nil, err
}
if err := q.InsertCheckDef(ctx, sqlcgen.InsertCheckDefParams{
EntityID: id,
TargetID: targetID,
TargetType: req.Body.TargetType,
Kind: string(req.Body.Kind),
Config: configJSON,
IntervalS: intervalS,
TimeoutS: timeoutS,
Zone: req.Body.Zone,
Enabled: enabled,
}); err != nil {
return nil, err
}
// Build response Check.
check := gen.Check{
Id: id,
Slug: entity.Slug,
Kind: gen.CheckKind(req.Body.Kind),
IntervalS: int(intervalS),
TimeoutS: int(timeoutS),
Enabled: enabled,
TargetType: req.Body.TargetType,
Zone: req.Body.Zone,
Version: int(entity.Version),
}
if req.Body.Config != nil {
check.Config = req.Body.Config
}
if targetID != nil && req.Body.Target != nil {
check.Target = req.Body.Target
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/checks", "",
map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateCheck201JSONResponse(check), nil
}
func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject) (gen.PatchCheckResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
// Parse If-Match
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
expectedVersion, err := parseIntIfMatch(ifMatch)
if err != nil {
return nil, err
}
_ = expectedVersion // check_defs don't track version via If-Match today, but we validate the header is present
if ifMatch == "" {
return nil, fmt.Errorf("%w: invalid If-Match header", domain.ErrInvalidInput)
}
// Get current check def
current, err := sqlcgen.New(s.pool).GetCheckDef(ctx, id)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: check %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Apply patch.
if req.Body.Config != nil {
current.Config, _ = json.Marshal(req.Body.Config)
}
if req.Body.IntervalS != nil {
current.IntervalS = int32(*req.Body.IntervalS)
}
if req.Body.TimeoutS != nil {
current.TimeoutS = int32(*req.Body.TimeoutS)
}
if req.Body.Enabled != nil {
current.Enabled = *req.Body.Enabled
}
if err := sqlcgen.New(tx).UpdateCheckDef(ctx, sqlcgen.UpdateCheckDefParams{
EntityID: id,
Kind: current.Kind,
Config: current.Config,
IntervalS: current.IntervalS,
TimeoutS: current.TimeoutS,
TargetID: current.TargetID,
TargetType: current.TargetType,
Zone: current.Zone,
Enabled: current.Enabled,
}); err != nil {
return nil, err
}
// Re-read to get updated timestamp.
updated, err := sqlcgen.New(tx).GetCheckDef(ctx, id)
if err != nil {
return nil, err
}
check := checkDefToGen(updated)
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/checks/"+req.Id, "",
map[string]any{"enabled": updated.Enabled}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchCheck200JSONResponse(check), nil
}
func checkDefToGen(cd sqlcgen.CheckDef) gen.Check {
c := gen.Check{
Id: cd.EntityID,
Kind: gen.CheckKind(cd.Kind),
IntervalS: int(cd.IntervalS),
TimeoutS: int(cd.TimeoutS),
Enabled: cd.Enabled,
TargetType: cd.TargetType,
Zone: cd.Zone,
}
var config map[string]any
if len(cd.Config) > 0 && json.Unmarshal(cd.Config, &config) == nil && len(config) > 0 {
c.Config = &config
}
return c
}
// parseIntIfMatch parses an integer from a raw If-Match header value (with quotes stripped).
func parseIntIfMatch(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("empty version")
}
var v int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid version: %q", s)
}
v = v*10 + int(c-'0')
}
return v, nil
}
// ─── Classifications ───────────────────────────────────────────────────
func (s *Server) ListClassifications(ctx context.Context, req gen.ListClassificationsRequestObject) (gen.ListClassificationsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
var route *string
if req.Params.Route != nil {
r := string(*req.Params.Route)
route = &r
}
rows, err := s.pool.Query(ctx, `
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
c.recommended_action, c.risk_class, c.route, c.blast_radius,
c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning,
c.correlation_id, c.created_at,
e.slug, COALESCE(se.slug, '') AS signal_slug, COALESCE(te.slug, '') AS target_slug
FROM classifications c
LEFT JOIN entities e ON e.id = c.entity_id
LEFT JOIN entities se ON se.id = c.signal_entity_id
LEFT JOIN entities te ON te.id = c.target_entity_id
WHERE ($1::text IS NULL OR c.route = $1)
AND ($2::text IS NULL OR e.slug > $2)
ORDER BY e.slug
LIMIT $3`,
route, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Classification{}
for rows.Next() {
var cls gen.Classification
var recActionJSON []byte
var reasoningJSON []byte
var blastRadius []uuid.UUID
var signalSlug, targetSlug string
if err := rows.Scan(&cls.Id, &cls.SignalId, &targetSlug, &cls.Action,
&recActionJSON, &cls.RiskClass, &cls.Route, &blastRadius,
&cls.PatternConfidence, &cls.SkillId, &cls.AutonomyCheck, &reasoningJSON,
&cls.CorrelationId, &cls.CreatedAt,
&cls.Target, &signalSlug, &targetSlug); err != nil {
return nil, err
}
if targetSlug != "" {
cls.Target = &targetSlug
}
var reasoning map[string]any
if json.Unmarshal(reasoningJSON, &reasoning) == nil {
cls.Reasoning = reasoning
}
if len(blastRadius) > 0 {
br := make([]string, len(blastRadius))
for i, id := range blastRadius {
br[i] = id.String()
}
cls.BlastRadius = &br
}
items = append(items, cls)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
if items[len(items)-1].Target != nil {
next = items[len(items)-1].Target
}
}
if items == nil {
items = []gen.Classification{}
}
return gen.ListClassifications200JSONResponse{Items: items, NextCursor: next}, nil
}
// ─── Executions ────────────────────────────────────────────────────────
func (s *Server) ListExecutions(ctx context.Context, req gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE ($1::text IS NULL OR e.status = $1)
AND ($2::text IS NULL OR te.slug > $2)
ORDER BY te.slug
LIMIT $3`,
req.Params.Status, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Execution{}
for rows.Next() {
var exec gen.Execution
var resultBytes []byte
var targetSlug string
if err := rows.Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug); err != nil {
return nil, err
}
var result map[string]any
if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
exec.Result = &result
}
// Target is stored as UUID, but we surface the slug
exec.Slug = targetSlug
items = append(items, exec)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Execution{}
}
return gen.ListExecutions200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) GetExecution(ctx context.Context, req gen.GetExecutionRequestObject) (gen.GetExecutionResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = s.pool.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
var result map[string]any
if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
exec.Result = &result
}
exec.Slug = targetSlug
return gen.GetExecution200JSONResponse(exec), nil
}
func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionRequestObject) (gen.RequestExecutionResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
if err != nil {
return nil, err
}
correlationID := uuid.New().String()
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Full UUID, not a truncated prefix — an 8-char prefix of a UUIDv7
// collides for real under back-to-back requests since the leading bytes
// encode a millisecond timestamp (observed live via the MCP run tool).
execSlug := "exec:" + id.String()
if _, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: execSlug,
Type: "execution",
Name: req.Body.Action + " on " + req.Body.Target,
Attributes: []byte("{}"),
}); err != nil {
return nil, err
}
if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
EntityID: id,
TargetEntityID: &targetID,
Action: req.Body.Action,
RiskClass: "unclassified", // will be classified by classifier
CorrelationID: correlationID,
}); err != nil {
return nil, err
}
// Re-read to get the full record.
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = tx.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
return nil, err
}
exec.Slug = targetSlug
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/executions", "",
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil {
return nil, auditErr
}
if eventErr := observability.Event(ctx, q, "execution.requested", &id,
"info", "oikos-api", "",
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); eventErr != nil {
return nil, eventErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.RequestExecution201JSONResponse(exec), nil
}
func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionRequestObject) (gen.CancelExecutionResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
if err := q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: id,
Status: "cancelled",
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
// Re-read.
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = tx.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
return nil, err
}
exec.Slug = targetSlug
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel",
&id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "",
map[string]any{"status": "cancelled"}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CancelExecution200JSONResponse(exec), nil
}
// ─── Approvals ─────────────────────────────────────────────────────────
func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
var status *string
if req.Params.Status != nil {
s := string(*req.Params.Status)
status = &s
}
var kind *string
if req.Params.Kind != nil {
k := string(*req.Params.Kind)
kind = &k
}
rows, err := s.pool.Query(ctx, `
SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload,
a.status, a.expires_at, a.decided_at, a.decided_by::text,
a.created_at, e.slug
FROM approvals a
JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id)
WHERE ($1::text IS NULL OR a.status = $1)
AND ($2::text IS NULL OR a.kind = $2)
AND ($3::text IS NULL OR e.slug > $3)
ORDER BY e.slug
LIMIT $4`,
status, kind, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Approval{}
for rows.Next() {
var a gen.Approval
var payloadBytes []byte
var decidedBy *string
if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes,
&a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy,
&a.CreatedAt, &a.Slug); err != nil {
return nil, err
}
a.DecidedBy = decidedBy
var payload map[string]any
if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil {
a.Payload = &payload
}
items = append(items, a)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Approval{}
}
return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
actorType, actor := actorInfo(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Verify HMAC token if provided (single-use, S5).
if req.Body.Token != nil && *req.Body.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 nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
}
if apprStatus != "pending" {
return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
}
if expiresAt.Before(time.Now()) {
return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
}
if *tokenHash != hashToken(*req.Body.Token) {
return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
}
}
// Map decision to status.
var status string
switch req.Body.Decision {
case gen.Approve:
status = "approved"
case gen.Deny:
status = "denied"
case gen.Revoke:
status = "revoked"
default:
return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision)
}
if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
EntityID: id,
Status: status,
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id)
}
return nil, err
}
// Re-read approval.
app, err := q.GetApprovalByID(ctx, id)
if err != nil {
return nil, err
}
approval := approvalToGen(app)
if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
map[string]any{"decision": status}); auditErr != nil {
return nil, auditErr
}
// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
map[string]any{"decision": status, "actor": actor}); evErr != nil {
return nil, evErr
}
// On approve: execute the linked gated command.
if status == "approved" {
var execID, targetID uuid.UUID
var actionStr, targetSlug, riskClass string
err := tx.QueryRow(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class
FROM executions e
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass)
if err == nil {
// Resolve target entity slug from targetID.
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
safego.Go("httpapi:executeApprovedAction", func() {
executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
})
// 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)
// Approving a plan step — by ANY route (this endpoint backs both
// the chat Approve button and chat-assent) — opens/extends the
// agent's assent window. This is the scope gate the Nomos
// auto-continuation worker checks: with the window open, the
// finished execution's result is fed back to the agent so it runs
// the plan to completion. Without opening it here, approving via
// the button (instead of typing "go ahead") would silently not
// auto-continue.
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 {
expires := time.Now().Add(30 * time.Minute).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(), expires)
// Approving a DESTRUCTIVE step via the button is exactly as
// explicit as a typed "I confirm" — the operator affirmatively
// clicked Approve on a card that said DESTRUCTIVE. Open the
// same short, target-scoped destructive window chat-assent's
// typed-confirm path opens, for parity: a multi-step
// destructive recovery (stop, then destroy) shouldn't need a
// fresh confirmation per click any more than it needs one per
// typed phrase.
if riskClass == "destructive" && targetSlug != "" {
dExpires := time.Now().Add(15 * time.Minute).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, dExpires)
}
}
slog.Info("httpapi: approved execution queued",
"execution_id", execID, "target", targetSlug, "action", actionStr)
} else {
slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
}
} else {
// Denied/revoked: reflect it on the linked execution too. Previously
// only the approvals row changed, so the execution stayed
// 'pending_approval' forever — any UI/poller reading execution
// status (not approval status) never saw the decision.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.DecideApproval200JSONResponse(approval), nil
}
func approvalToGen(a sqlcgen.Approval) gen.Approval {
app := gen.Approval{
Id: a.EntityID,
Action: a.Action,
RiskClass: a.RiskClass,
Kind: gen.ApprovalKind(a.Kind),
Status: gen.ApprovalStatus(a.Status),
ExpiresAt: a.ExpiresAt,
DecidedAt: a.DecidedAt,
CreatedAt: a.CreatedAt,
}
if a.DecidedBy != nil {
s := a.DecidedBy.String()
app.DecidedBy = &s
}
var payload map[string]any
if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 {
app.Payload = &payload
}
return app
}
// ─── Patterns ──────────────────────────────────────────────────────────
func (s *Server) ListPatterns(ctx context.Context, req gen.ListPatternsRequestObject) (gen.ListPatternsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT p.entity_id, e.slug, p.applies_type, p.action, p.pattern, p.confidence,
p.evidence_count, p.success_count, p.failure_count, p.status,
p.quarantined, p.version, p.last_validated_at
FROM patterns p
JOIN entities e ON e.id = p.entity_id
WHERE ($1::text IS NULL OR p.status = $1)
AND ($2::text IS NULL OR p.applies_type = $2)
AND ($3::text IS NULL OR p.action = $3)
ORDER BY p.applies_type, p.action
LIMIT $4`,
req.Params.Status, req.Params.EntityType, req.Params.Action, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Pattern{}
for rows.Next() {
var p gen.Pattern
if err := rows.Scan(&p.Id, &p.Slug, &p.AppliesType, &p.Action, &p.Pattern,
&p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount,
&p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt); err != nil {
return nil, err
}
items = append(items, p)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Pattern{}
}
return gen.ListPatterns200JSONResponse{Items: items}, nil
}
func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestObject) (gen.PatchPatternResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
if req.Body.Status != nil {
status := string(*req.Body.Status)
if err := q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
EntityID: id,
Status: status,
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
}
if req.Body.Quarantined != nil {
if err := q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
EntityID: id,
Quarantined: *req.Body.Quarantined,
}); err != nil {
return nil, err
}
}
// Re-read.
var p gen.Pattern
err = tx.QueryRow(ctx, `
SELECT entity_id, applies_type, action, pattern, confidence,
evidence_count, success_count, failure_count, status,
quarantined, version, last_validated_at
FROM patterns WHERE entity_id = $1`, id).
Scan(&p.Id, &p.AppliesType, &p.Action, &p.Pattern,
&p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount,
&p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
&id, "PATCH", "/api/v1/patterns/"+req.Id, "",
map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchPattern200JSONResponse(p), nil
}
// ─── Skills ────────────────────────────────────────────────────────────
func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject) (gen.ListSkillsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT s.entity_id, s.version, s.name, s.procedure, s.applies_type,
s.action, s.pattern_ids, s.status, s.success_rate,
s.changed_by::text, s.change_reason, s.last_used_at
FROM skills s
WHERE ($1::text IS NULL OR s.status = $1)
AND ($2::text IS NULL OR s.applies_type = $2)
AND ($3::text IS NULL OR s.action = $3)
ORDER BY s.name, s.version DESC`,
req.Params.Status, req.Params.AppliesTo, req.Params.Action)
if err != nil {
return nil, err
}
defer rows.Close()
// Deduplicate to latest version per skill (the ORDER BY name, version DESC
// means the first row per name is the latest).
seen := map[string]bool{}
items := []gen.Skill{}
for rows.Next() {
var s gen.Skill
var procBytes []byte
var patternIDs []uuid.UUID
if err := rows.Scan(&s.Id, &s.Version, &s.Name, &procBytes, &s.AppliesType,
&s.Action, &patternIDs, &s.Status, &s.SuccessRate,
&s.ChangedBy, &s.ChangeReason, &s.LastUsedAt); err != nil {
return nil, err
}
if seen[s.Id.String()] {
continue
}
seen[s.Id.String()] = true
if err := json.Unmarshal(procBytes, &s.Procedure); err != nil {
slog.Warn("phase3: unmarshal skill procedure", "skill", s.Name, "error", err)
}
if len(patternIDs) > 0 {
pids := make([]string, len(patternIDs))
for i, pid := range patternIDs {
pids[i] = pid.String()
}
s.PatternIds = &pids
}
items = append(items, s)
if len(items) > limit {
break
}
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Skill{}
}
return gen.ListSkills200JSONResponse{Items: items}, nil
}
func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject) (gen.PatchSkillResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
if req.Body.Status != nil {
if err := q.UpdateSkillStatus(ctx, sqlcgen.UpdateSkillStatusParams{
EntityID: id,
Status: string(*req.Body.Status),
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
}
// Re-read skill.
var skill gen.Skill
var procBytes []byte
var patternIDs []uuid.UUID
err = tx.QueryRow(ctx, `
SELECT entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, success_rate, changed_by::text,
change_reason, last_used_at
FROM skills WHERE entity_id = $1 ORDER BY version DESC LIMIT 1`, id).
Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType,
&skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate,
&skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil {
slog.Warn("phase3: unmarshal skill proc", "error", err)
}
if len(patternIDs) > 0 {
pids := make([]string, len(patternIDs))
for i, pid := range patternIDs {
pids[i] = pid.String()
}
skill.PatternIds = &pids
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
&id, "PATCH", "/api/v1/skills/"+req.Id, "",
map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchSkill200JSONResponse(skill), nil
}
func (s *Server) ListSkillVersions(ctx context.Context, req gen.ListSkillVersionsRequestObject) (gen.ListSkillVersionsResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, success_rate, changed_by::text,
change_reason, last_used_at
FROM skills WHERE entity_id = $1
ORDER BY version DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Skill{}
for rows.Next() {
var skill gen.Skill
var procBytes []byte
var patternIDs []uuid.UUID
if err := rows.Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType,
&skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate,
&skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil {
slog.Warn("phase3: unmarshal skill proc", "error", err)
}
if len(patternIDs) > 0 {
pids := make([]string, len(patternIDs))
for i, pid := range patternIDs {
pids[i] = pid.String()
}
skill.PatternIds = &pids
}
items = append(items, skill)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Skill{}
}
return gen.ListSkillVersions200JSONResponse{Items: items}, nil
}
// ─── Approval Rules (Policy) ───────────────────────────────────────────
func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, entity_type, action, risk_class, autonomy_level,
COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''),
version, updated_at
FROM approval_rules ORDER BY entity_type, action`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.ApprovalRule{}
for rows.Next() {
var rule gen.ApprovalRule
var scopeSlug string
if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action,
&rule.RiskClass, &rule.AutonomyLevel, &scopeSlug,
&rule.Version); err != nil {
return nil, err
}
if scopeSlug != "" {
rule.ScopeEntity = &scopeSlug
}
items = append(items, rule)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.ApprovalRule{}
}
return gen.ListApprovalRules200JSONResponse{Items: items}, nil
}
func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
var scopeEntity *uuid.UUID
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
if rerr != nil {
return nil, rerr
}
scopeEntity = &se
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
string(req.Body.AutonomyLevel), scopeEntity)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists,
coalesceStr(req.Body.EntityType, "*"), req.Body.Action)
}
return nil, err
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
&id, "POST", "/api/v1/policy/approval-rules", "",
map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
// Return 202 pending approval (dual-control).
return gen.CreateApprovalRule202JSONResponse{}, nil
}
func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
var scopeEntity *uuid.UUID
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
if rerr != nil {
return nil, rerr
}
scopeEntity = &se
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
result, err := tx.Exec(ctx, `
UPDATE approval_rules
SET entity_type = COALESCE($2, entity_type),
action = COALESCE($3, action),
risk_class = COALESCE($4, risk_class),
autonomy_level = COALESCE($5, autonomy_level),
scope_entity = COALESCE($6, scope_entity),
version = version + 1,
updated_at = now()
WHERE id = $1`,
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
string(req.Body.AutonomyLevel), scopeEntity)
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id)
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
map[string]any{"action": req.Body.Action}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchApprovalRule202JSONResponse{}, nil
}
// ─── Autonomy Settings ─────────────────────────────────────────────────
func (s *Server) GetAutonomySettings(ctx context.Context, req gen.GetAutonomySettingsRequestObject) (gen.GetAutonomySettingsResponseObject, error) {
rows, err := s.pool.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.AutonomySetting{}
for rows.Next() {
var as gen.AutonomySetting
if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil {
return nil, err
}
items = append(items, as)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.AutonomySetting{}
}
return gen.GetAutonomySettings200JSONResponse{Items: items}, nil
}
func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonomySettingsRequestObject) (gen.PatchAutonomySettingsResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
for key, value := range *req.Body {
_, err := tx.Exec(ctx, `
INSERT INTO autonomy_settings (key, value, version, updated_at)
VALUES ($1, $2, 1, now())
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, version = autonomy_settings.version + 1, updated_at = now()`,
key, value)
if err != nil {
return nil, err
}
}
// Re-read all settings.
rows, err := tx.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.AutonomySetting{}
for rows.Next() {
var as gen.AutonomySetting
if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil {
return nil, err
}
items = append(items, as)
}
if rows.Err() != nil {
return nil, rows.Err()
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
nil, "PATCH", "/api/v1/policy/autonomy", "",
map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchAutonomySettings200JSONResponse{Items: items}, nil
}
// keysOfMap returns the keys of a map[string]string.
func keysOfMap(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// ─── Risk Classes ──────────────────────────────────────────────────────
func (s *Server) ListRiskClasses(ctx context.Context, req gen.ListRiskClassesRequestObject) (gen.ListRiskClassesResponseObject, error) {
rows, err := s.pool.Query(ctx, `SELECT name, description, approval_required, autonomy_allowed FROM risk_classes ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.RiskClass{}
for rows.Next() {
var rc gen.RiskClass
if err := rows.Scan(&rc.Name, &rc.Description, &rc.ApprovalRequired, &rc.AutonomyAllowed); err != nil {
return nil, err
}
items = append(items, rc)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.RiskClass{}
}
return gen.ListRiskClasses200JSONResponse{Items: items}, nil
}
// ─── Relationships ─────────────────────────────────────────────────────
func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
sourceID, err := s.resolveEntityID(ctx, req.Body.Source)
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
if err != nil {
return nil, err
}
attrsJSON := []byte("{}")
if req.Body.Attributes != nil {
attrsJSON, _ = json.Marshal(req.Body.Attributes)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, $3, $4, now())`,
sourceID, targetID, req.Body.Type, attrsJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists",
domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target)
}
return nil, err
}
rel := gen.Relationship{
Source: req.Body.Source,
Target: req.Body.Target,
Type: req.Body.Type,
ValidFrom: time.Now(),
}
if req.Body.Attributes != nil {
rel.Attributes = req.Body.Attributes
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/relationships", "",
map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateRelationship201JSONResponse(rel), nil
}
func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) {
sourceID, err := s.resolveEntityID(ctx, req.Params.Source)
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Params.Target)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
result, err := tx.Exec(ctx, `
UPDATE relationships
SET valid_to = now()
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL`,
sourceID, targetID, req.Params.RelType)
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, fmt.Errorf("%w: active relationship %s:%s:%s",
domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target)
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
nil, "DELETE", "/api/v1/relationships", "",
map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.EndRelationship204Response{}, nil
}
// ─── Entity Types (Ontology) ───────────────────────────────────────────
func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeRequestObject) (gen.CreateEntityTypeResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
isAbstract := false
if req.Body.IsAbstract != nil {
isAbstract = *req.Body.IsAbstract
}
attrsSchemaJSON := []byte("null")
if req.Body.AttributeSchema != nil {
attrsSchemaJSON, _ = json.Marshal(req.Body.AttributeSchema)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')`,
req.Body.Name, req.Body.ParentType, isAbstract, req.Body.Domain,
string(req.Body.Layer), req.Body.Description, req.Body.LifecycleId, attrsSchemaJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: entity type %q already exists", domain.ErrAlreadyExists, req.Body.Name)
}
return nil, err
}
// Re-read.
var et gen.EntityType
var schemaBytes []byte
err = tx.QueryRow(ctx, `
SELECT name, parent_type, is_abstract, domain, layer, description,
lifecycle_id, attribute_schema, schema_version, status
FROM entity_types WHERE name = $1`, req.Body.Name).
Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer,
&et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status)
if err != nil {
return nil, err
}
var schema map[string]any
if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil {
et.AttributeSchema = &schema
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/ontology/entity-types", "",
map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateEntityType201JSONResponse(et), nil
}
func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeRequestObject) (gen.PatchEntityTypeResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Build dynamic update.
sets := []string{}
args := []any{}
argIdx := 2
if req.Body.Description != nil {
sets = append(sets, fmt.Sprintf("description = $%d", argIdx))
args = append(args, *req.Body.Description)
argIdx++
}
if req.Body.Status != nil {
sets = append(sets, fmt.Sprintf("status = $%d", argIdx))
args = append(args, string(*req.Body.Status))
argIdx++
}
if req.Body.AttributeSchema != nil {
schemaJSON, _ := json.Marshal(req.Body.AttributeSchema)
sets = append(sets, fmt.Sprintf("attribute_schema = $%d", argIdx))
args = append(args, schemaJSON)
argIdx++
}
if len(sets) == 0 {
return nil, fmt.Errorf("%w: no fields to update", domain.ErrInvalidInput)
}
sets = append(sets, "schema_version = schema_version + 1, updated_at = now()")
query := fmt.Sprintf(`UPDATE entity_types SET %s WHERE name = $1`, strings.Join(sets, ", "))
finalArgs := append([]any{req.Name}, args...)
result, err := tx.Exec(ctx, query, finalArgs...)
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Name)
}
// Re-read.
var et gen.EntityType
var schemaBytes []byte
err = tx.QueryRow(ctx, `
SELECT name, parent_type, is_abstract, domain, layer, description,
lifecycle_id, attribute_schema, schema_version, status
FROM entity_types WHERE name = $1`, req.Name).
Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer,
&et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status)
if err != nil {
return nil, err
}
var schema map[string]any
if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil {
et.AttributeSchema = &schema
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "",
map[string]any{"status": req.Body.Status}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchEntityType200JSONResponse(et), nil
}
// ─── Metrics ───────────────────────────────────────────────────────────
func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) {
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
}
entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
if err != nil {
return nil, err
}
from := time.Now().Add(-24 * time.Hour)
if req.Params.From != nil {
from = *req.Params.From
}
to := time.Now()
if req.Params.To != nil {
to = *req.Params.To
}
var metricNames []string
if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
metricNames = *req.Params.Metric
} else {
// metric omitted: report every metric recorded for this entity in range.
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT metric FROM metric_samples
WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
ORDER BY metric`, entityID, from, to)
if err != nil {
return nil, err
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return nil, err
}
metricNames = append(metricNames, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
}
items := []gen.MetricSeries{}
for _, metricName := range metricNames {
series := gen.MetricSeries{
EntityId: entityID.String(),
Metric: metricName,
Rollup: gen.MetricSeriesRollupRaw,
}
rows, err := s.pool.Query(ctx, `
SELECT ts, value
FROM metric_samples
WHERE entity_id = $1 AND metric = $2
AND ts >= $3 AND ts <= $4
ORDER BY ts ASC`,
entityID, metricName, from, to)
if err != nil {
return nil, err
}
samples := []struct {
Avg *float32 `json:"avg"`
Count *int `json:"count"`
Max *float32 `json:"max"`
Min *float32 `json:"min"`
Ts time.Time `json:"ts"`
Value *float32 `json:"value"`
}{}
for rows.Next() {
var ts time.Time
var val float64
if err := rows.Scan(&ts, &val); err != nil {
rows.Close()
return nil, err
}
f := float32(val)
samples = append(samples, struct {
Avg *float32 `json:"avg"`
Count *int `json:"count"`
Max *float32 `json:"max"`
Min *float32 `json:"min"`
Ts time.Time `json:"ts"`
Value *float32 `json:"value"`
}{Value: &f, Ts: ts})
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
series.Samples = samples
items = append(items, series)
}
if items == nil {
items = []gen.MetricSeries{}
}
return gen.QueryMetrics200JSONResponse{Items: items}, nil
}
func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) {
entityID, err := s.resolveEntityID(ctx, req.EntityId)
if err != nil {
return nil, err
}
from := time.Now().Add(-7 * 24 * time.Hour)
if req.Params.From != nil {
from = *req.Params.From
}
rows, err := s.pool.Query(ctx, `
SELECT metric,
ROUND(avg(value)::numeric, 2) AS avg_val,
ROUND(stddev(value)::numeric, 2) AS std_val,
count(*) AS sample_count,
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
FROM metric_samples
WHERE entity_id = $1 AND ts >= $2
GROUP BY metric
ORDER BY metric`, entityID, from)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Trend{}
for rows.Next() {
var t gen.Trend
var avgVal, stdVal, slopeNum pgtype.Numeric
var sampleCount int
if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil {
return nil, err
}
// Determine direction.
if slopeNum.Valid {
f, _ := slopeNum.Float64Value()
t.Slope = float32Ptr(float32(f.Float64))
if f.Float64 > 0.01 {
t.Direction = gen.Improving
} else if f.Float64 < -0.01 {
t.Direction = gen.Degrading
} else {
t.Direction = gen.Stable
}
} else {
t.Direction = gen.Unknown
}
items = append(items, t)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Trend{}
}
return gen.GetTrends200JSONResponse{Items: items}, nil
}
func float32Ptr(f float32) *float32 {
return &f
}
// ─── Agent Activity (stub) ─────────────────────────────────────────────
func (s *Server) QueryAgentActivity(ctx context.Context, request gen.QueryAgentActivityRequestObject) (gen.QueryAgentActivityResponseObject, error) {
limit := clampLimit(request.Params.Limit)
from := time.Now().Add(-24 * time.Hour)
if request.Params.From != nil {
from = *request.Params.From
}
to := time.Now()
if request.Params.To != nil {
to = *request.Params.To
}
var agentID *string
if request.Params.AgentId != nil {
a := *request.Params.AgentId
agentID = &a
}
var activityType *string
if request.Params.ActivityType != nil {
a := string(*request.Params.ActivityType)
activityType = &a
}
var entityID *string
if request.Params.EntityId != nil {
a := *request.Params.EntityId
entityID = &a
}
var cursorID *int
if request.Params.Cursor != nil && *request.Params.Cursor != "" {
if id, err := parseIntOrZero(*request.Params.Cursor); err == nil && id > 0 {
cursorID = &id
}
}
rows, err := s.pool.Query(ctx, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, input_summary, output_summary,
duration_ms, token_count, success, correlation_id
FROM agent_activity
WHERE ts >= $1 AND ts <= $2
AND ($3::text IS NULL OR agent_id::text = $3)
AND ($4::text IS NULL OR activity_type = $4)
AND ($5::text IS NULL OR entity_id::text = $5)
AND ($6::bigint IS NULL OR id < $6::bigint)
ORDER BY id DESC
LIMIT $7`,
from, to, agentID, activityType, entityID, cursorID, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.AgentActivity{}
for rows.Next() {
var a gen.AgentActivity
if err := rows.Scan(&a.Id, &a.Ts, &a.AgentId, &a.SessionId,
&a.ActivityType, &a.ToolName, &a.EntityId,
&a.InputSummary, &a.OutputSummary,
&a.DurationMs, &a.TokenCount, &a.Success,
&a.CorrelationId); err != nil {
return nil, err
}
items = append(items, a)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
lastID := fmt.Sprintf("%d", items[len(items)-1].Id)
next = &lastID
}
if items == nil {
items = []gen.AgentActivity{}
}
return gen.QueryAgentActivity200JSONResponse{Items: items, NextCursor: next}, nil
}
// ─── Helpers ───────────────────────────────────────────────────────────
func coalesceStr(s *string, def string) string {
if s == nil || *s == "" {
return def
}
return *s
}
func parseIntOrZero(s string) (int, error) {
var n int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid integer: %q", s)
}
n = n*10 + int(c-'0')
}
return n, nil
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}